Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
5f6c01387a | 1 year ago |
|
|
9897685192 | 1 year ago |
@ -1,8 +0,0 @@ |
||||
## Padel Club |
||||
|
||||
This is the main directory of a Swift app that helps padel tournament organizers. |
||||
The project is structured around three projects linked in the PadelClub.xcworkspace: |
||||
- PadelClub: this one, which mostly contains the UI for the project |
||||
- PadelClubData: the business logic for the app |
||||
- LeStorage: a local storage with a synchronization layer |
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,78 +0,0 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<Scheme |
||||
LastUpgradeVersion = "1630" |
||||
version = "1.7"> |
||||
<BuildAction |
||||
parallelizeBuildables = "YES" |
||||
buildImplicitDependencies = "YES" |
||||
buildArchitectures = "Automatic"> |
||||
<BuildActionEntries> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "YES" |
||||
buildForProfiling = "YES" |
||||
buildForArchiving = "YES" |
||||
buildForAnalyzing = "YES"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
</BuildActionEntries> |
||||
</BuildAction> |
||||
<TestAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
shouldAutocreateTestPlan = "YES"> |
||||
</TestAction> |
||||
<LaunchAction |
||||
buildConfiguration = "Release" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
launchStyle = "0" |
||||
useCustomWorkingDirectory = "NO" |
||||
ignoresPersistentStateOnLaunch = "NO" |
||||
debugDocumentVersioning = "YES" |
||||
debugServiceExtension = "internal" |
||||
allowLocationSimulation = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</LaunchAction> |
||||
<ProfileAction |
||||
buildConfiguration = "Release" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
savedToolIdentifier = "" |
||||
useCustomWorkingDirectory = "NO" |
||||
debugDocumentVersioning = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</ProfileAction> |
||||
<AnalyzeAction |
||||
buildConfiguration = "Debug"> |
||||
</AnalyzeAction> |
||||
<ArchiveAction |
||||
buildConfiguration = "Release" |
||||
revealArchiveInOrganizer = "YES"> |
||||
</ArchiveAction> |
||||
</Scheme> |
||||
@ -1,33 +0,0 @@ |
||||
{ |
||||
"colors" : [ |
||||
{ |
||||
"color" : { |
||||
"color-space" : "srgb", |
||||
"components" : { |
||||
"alpha" : "1.000", |
||||
"blue" : "0.808", |
||||
"green" : "0.906", |
||||
"red" : "0.980" |
||||
} |
||||
}, |
||||
"idiom" : "universal" |
||||
}, |
||||
{ |
||||
"appearances" : [ |
||||
{ |
||||
"appearance" : "luminosity", |
||||
"value" : "dark" |
||||
} |
||||
], |
||||
"color" : { |
||||
"platform" : "ios", |
||||
"reference" : "systemGrayColor" |
||||
}, |
||||
"idiom" : "universal" |
||||
} |
||||
], |
||||
"info" : { |
||||
"author" : "xcode", |
||||
"version" : 1 |
||||
} |
||||
} |
||||
@ -1,33 +0,0 @@ |
||||
{ |
||||
"colors" : [ |
||||
{ |
||||
"color" : { |
||||
"platform" : "ios", |
||||
"reference" : "systemGrayColor" |
||||
}, |
||||
"idiom" : "universal" |
||||
}, |
||||
{ |
||||
"appearances" : [ |
||||
{ |
||||
"appearance" : "luminosity", |
||||
"value" : "dark" |
||||
} |
||||
], |
||||
"color" : { |
||||
"color-space" : "srgb", |
||||
"components" : { |
||||
"alpha" : "1.000", |
||||
"blue" : "0x00", |
||||
"green" : "0xD2", |
||||
"red" : "0xFF" |
||||
} |
||||
}, |
||||
"idiom" : "universal" |
||||
} |
||||
], |
||||
"info" : { |
||||
"author" : "xcode", |
||||
"version" : 1 |
||||
} |
||||
} |
||||
@ -0,0 +1,86 @@ |
||||
// |
||||
// Club+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension Club { |
||||
var isValid: Bool { |
||||
name.isEmpty == false && name.count > 3 |
||||
} |
||||
|
||||
func automaticShortName() -> String { |
||||
name.acronym() |
||||
} |
||||
|
||||
enum AcronymMode: String, CaseIterable { |
||||
case automatic = "Automatique" |
||||
case custom = "Personalisée" |
||||
} |
||||
|
||||
func shortNameMode() -> AcronymMode { |
||||
(acronym.isEmpty || acronym == automaticShortName()) ? .automatic : .custom |
||||
} |
||||
|
||||
func hasTenupId() -> Bool { |
||||
code != nil |
||||
} |
||||
|
||||
func federalLink() -> URL? { |
||||
guard let code else { return nil } |
||||
return URL(string: "https://tenup.fft.fr/club/\(code)") |
||||
} |
||||
|
||||
func courtName(atIndex courtIndex: Int) -> String { |
||||
courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) |
||||
} |
||||
|
||||
func courtNameIfAvailable(atIndex courtIndex: Int) -> String? { |
||||
customizedCourts.first(where: { $0.index == courtIndex })?.name |
||||
} |
||||
|
||||
func update(fromClub club: Club) { |
||||
self.acronym = club.acronym |
||||
self.name = club.name |
||||
self.phone = club.phone |
||||
self.code = club.code |
||||
self.address = club.address |
||||
self.city = club.city |
||||
self.zipCode = club.zipCode |
||||
self.latitude = club.latitude |
||||
self.longitude = club.longitude |
||||
} |
||||
|
||||
func hasBeenCreated(by creatorId: String?) -> Bool { |
||||
return creatorId == creator || creator == nil |
||||
} |
||||
|
||||
func isFavorite() -> Bool { |
||||
return DataStore.shared.user.clubs.contains(where: { $0 == self.id }) |
||||
} |
||||
|
||||
static func findOrCreate(name: String, code: String?, city: String? = nil, zipCode: String? = nil) -> Club { |
||||
|
||||
/* |
||||
|
||||
identify a club : code, name, ?? |
||||
|
||||
*/ |
||||
let club: Club? = DataStore.shared.clubs.first(where: { (code == nil && $0.name == name && $0.city == city && $0.zipCode == zipCode) || code != nil && $0.code == code }) |
||||
|
||||
if let club { |
||||
return club |
||||
} else { |
||||
return Club(creator: StoreCenter.main.userId, name: name, code: code, city: city, zipCode: zipCode) |
||||
} |
||||
} |
||||
|
||||
func shareURL() -> URL? { |
||||
return URL(string: URLs.main.url.appending(path: "?club=\(id)").absoluteString.removingPercentEncoding!) |
||||
} |
||||
|
||||
} |
||||
@ -1,30 +0,0 @@ |
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E214" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="v1.1"> |
||||
<entity name="ImportedPlayer" representedClassName=".ImportedPlayer" syncable="YES" codeGenerationType="class"> |
||||
<attribute name="assimilation" attributeType="String"/> |
||||
<attribute name="bestRank" optional="YES" attributeType="String"/> |
||||
<attribute name="birthYear" optional="YES" 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:(fullName)"/> |
||||
<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="progression" optional="YES" attributeType="Integer 64" defaultValueString="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,73 @@ |
||||
// |
||||
// TournamentCategory+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension TournamentCategory { |
||||
|
||||
} |
||||
|
||||
extension TournamentType { |
||||
|
||||
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
switch self { |
||||
case .classic: |
||||
return "Classique" |
||||
case .doubleBrackets: |
||||
return "Double Poules" |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension TournamentBuild { |
||||
|
||||
var computedLabel: String { |
||||
if age == .senior { return localizedLabel() } |
||||
return localizedLabel() + " " + localizedAge |
||||
} |
||||
|
||||
var localizedTitle: String { |
||||
level.localizedLabel() + " " + category.localizedLabel() |
||||
} |
||||
|
||||
} |
||||
|
||||
extension TeamPosition { |
||||
|
||||
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
var shortName: String { |
||||
switch self { |
||||
case .one: |
||||
return "#1" |
||||
case .two: |
||||
return "#2" |
||||
} |
||||
} |
||||
|
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return "Équipe " + shortName |
||||
case .short: |
||||
return shortName |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension MatchFormat { |
||||
|
||||
func formattedEstimatedBreakDuration() -> String { |
||||
var label = Duration.seconds(breakTime.breakTime * 60).formatted(.units(allowed: [.minutes])) |
||||
if breakTime.matchCount > 1 { |
||||
label += " après \(breakTime.matchCount) match" |
||||
label += breakTime.matchCount.pluralSuffix |
||||
} |
||||
return label |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,55 @@ |
||||
// |
||||
// GroupStage+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension GroupStage { |
||||
|
||||
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
if let name { return name } |
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return "Poule \(index + 1)" |
||||
case .short: |
||||
return "#\(index + 1)" |
||||
} |
||||
} |
||||
|
||||
func pasteData() -> String { |
||||
var data: [String] = [] |
||||
data.append(self.groupStageTitle()) |
||||
teams().forEach { team in |
||||
data.append(team.teamLabelRanked(displayRank: true, displayTeamName: true)) |
||||
} |
||||
|
||||
return data.joined(separator: "\n") |
||||
} |
||||
|
||||
} |
||||
|
||||
extension GroupStage: Selectable { |
||||
func selectionLabel(index: Int) -> String { |
||||
groupStageTitle() |
||||
} |
||||
|
||||
func badgeValue() -> Int? { |
||||
return runningMatches(playedMatches: _matches()).count |
||||
} |
||||
|
||||
func badgeValueColor() -> Color? { |
||||
return nil |
||||
} |
||||
|
||||
func badgeImage() -> Badge? { |
||||
if teams().count < size { |
||||
return .xmark |
||||
} else { |
||||
return hasEnded() ? .checkmark : nil |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,89 @@ |
||||
// |
||||
// Match+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension Match { |
||||
|
||||
func matchWarningSubject() -> String { |
||||
[roundTitle(), matchTitle(.short)].compacted().joined(separator: " ") |
||||
} |
||||
|
||||
func matchWarningMessage() -> String { |
||||
[roundTitle(), matchTitle(.short), startDate?.localizedDate(), courtName()].compacted().joined(separator: "\n") |
||||
} |
||||
|
||||
func matchTitle(_ displayStyle: DisplayStyle = .wide, inMatches matches: [Match]? = nil) -> String { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func matchTitle", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
if let groupStageObject { |
||||
return groupStageObject.localizedMatchUpLabel(for: index) |
||||
} |
||||
|
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return "Match \(indexInRound(in: matches) + 1)" |
||||
case .short: |
||||
return "#\(indexInRound(in: matches) + 1)" |
||||
} |
||||
} |
||||
|
||||
func roundTitle() -> String? { |
||||
if groupStage != nil { return groupStageObject?.groupStageTitle() } |
||||
else if let roundObject { return roundObject.roundTitle() } |
||||
else { return nil } |
||||
} |
||||
|
||||
func teamNames(_ team: TeamRegistration?) -> [String]? { |
||||
return team?.players().map { $0.playerLabel() } |
||||
} |
||||
|
||||
func updateScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { |
||||
let teamScoreOne = teamScore(.one) ?? TeamScore(match: id, team: team(.one)) |
||||
teamScoreOne.score = matchDescriptor.teamOneScores.joined(separator: ",") |
||||
let teamScoreTwo = teamScore(.two) ?? TeamScore(match: id, team: team(.two)) |
||||
teamScoreTwo.score = matchDescriptor.teamTwoScores.joined(separator: ",") |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo]) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
matchFormat = matchDescriptor.matchFormat |
||||
} |
||||
|
||||
func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { |
||||
updateScore(fromMatchDescriptor: matchDescriptor) |
||||
if endDate == nil { |
||||
endDate = Date() |
||||
} |
||||
if startDate == nil { |
||||
startDate = endDate?.addingTimeInterval(Double(-getDuration()*60)) |
||||
} |
||||
|
||||
let teamOne = team(matchDescriptor.winner) |
||||
let teamTwo = team(matchDescriptor.winner.otherTeam) |
||||
|
||||
teamOne?.hasArrived() |
||||
teamTwo?.hasArrived() |
||||
|
||||
winningTeamId = teamOne?.id |
||||
losingTeamId = teamTwo?.id |
||||
|
||||
confirmed = true |
||||
|
||||
groupStageObject?.updateGroupStageState() |
||||
roundObject?.updateTournamentState() |
||||
updateFollowingMatchTeamScore() |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
// |
||||
// MatchScheduler+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension MatchScheduler { |
||||
|
||||
} |
||||
@ -0,0 +1,230 @@ |
||||
// |
||||
// PlayerRegistration+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension PlayerRegistration { |
||||
|
||||
internal init(importedPlayer: ImportedPlayer) { |
||||
self.teamRegistration = "" |
||||
self.firstName = (importedPlayer.firstName ?? "").trimmed.capitalized |
||||
self.lastName = (importedPlayer.lastName ?? "").trimmed.uppercased() |
||||
self.licenceId = importedPlayer.license ?? nil |
||||
self.rank = Int(importedPlayer.rank) |
||||
self.sex = importedPlayer.male ? .male : .female |
||||
self.tournamentPlayed = importedPlayer.tournamentPlayed |
||||
self.points = importedPlayer.getPoints() |
||||
self.clubName = importedPlayer.clubName |
||||
self.ligueName = importedPlayer.ligueName |
||||
self.assimilation = importedPlayer.assimilation |
||||
self.source = .frenchFederation |
||||
} |
||||
|
||||
internal init?(federalData: [String], sex: Int, sexUnknown: Bool) { |
||||
let _lastName = federalData[0].trimmed.uppercased() |
||||
let _firstName = federalData[1].trimmed.capitalized |
||||
if _lastName.isEmpty && _firstName.isEmpty { return nil } |
||||
lastName = _lastName |
||||
firstName = _firstName |
||||
birthdate = federalData[2] |
||||
licenceId = federalData[3] |
||||
clubName = federalData[4] |
||||
let stringRank = federalData[5] |
||||
if stringRank.isEmpty { |
||||
rank = nil |
||||
} else { |
||||
rank = Int(stringRank) |
||||
} |
||||
let _email = federalData[6] |
||||
if _email.isEmpty == false { |
||||
self.email = _email |
||||
} |
||||
let _phoneNumber = federalData[7] |
||||
if _phoneNumber.isEmpty == false { |
||||
self.phoneNumber = _phoneNumber |
||||
} |
||||
|
||||
source = .beachPadel |
||||
if sexUnknown { |
||||
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) { |
||||
self.sex = .female |
||||
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) { |
||||
self.sex = .male |
||||
} else { |
||||
self.sex = nil |
||||
} |
||||
} else { |
||||
self.sex = PlayerSexType(rawValue: sex) |
||||
} |
||||
} |
||||
|
||||
func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized |
||||
case .short: |
||||
let names = lastName.components(separatedBy: .whitespaces) |
||||
if lastName.components(separatedBy: .whitespaces).count > 1 { |
||||
if let firstLongWord = names.first(where: { $0.count > 3 }) { |
||||
return firstLongWord.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "." |
||||
} |
||||
} |
||||
return lastName.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "." |
||||
} |
||||
} |
||||
|
||||
func pasteData(_ exportFormat: ExportFormat = .rawText) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: exportFormat.separator()) |
||||
case .csv: |
||||
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
@objc |
||||
var canonicalName: String { |
||||
playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased() |
||||
} |
||||
|
||||
func isValidLicenseNumber(year: Int) -> Bool { |
||||
guard let licenceId else { return false } |
||||
guard licenceId.isLicenseNumber else { return false } |
||||
guard licenceId.suffix(6) == "(\(year))" else { return false } |
||||
return true |
||||
} |
||||
|
||||
@MainActor |
||||
func updateRank(from sources: [CSVParser], lastRank: Int) async throws { |
||||
if let dataFound = try await history(from: sources) { |
||||
rank = dataFound.rankValue?.toInt() |
||||
points = dataFound.points |
||||
tournamentPlayed = dataFound.tournamentCountValue?.toInt() |
||||
} else { |
||||
rank = lastRank |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension PlayerRegistration: PlayerHolder { |
||||
func getAssimilatedAsMaleRank() -> Int? { |
||||
nil |
||||
} |
||||
|
||||
func getFirstName() -> String { |
||||
firstName |
||||
} |
||||
|
||||
func getLastName() -> String { |
||||
lastName |
||||
} |
||||
|
||||
func getPoints() -> Double? { |
||||
self.points |
||||
} |
||||
|
||||
func getRank() -> Int? { |
||||
rank |
||||
} |
||||
|
||||
func isUnranked() -> Bool { |
||||
rank == nil |
||||
} |
||||
|
||||
func formattedRank() -> String { |
||||
self.rankLabel() |
||||
} |
||||
|
||||
func formattedLicense() -> String { |
||||
if let licenceId { return licenceId.computedLicense } |
||||
return "aucune licence" |
||||
} |
||||
|
||||
var male: Bool { |
||||
isMalePlayer() |
||||
} |
||||
|
||||
func history(from sources: [CSVParser]) async throws -> Line? { |
||||
guard let license = licenceId?.strippedLicense else { |
||||
return try await historyFromName(from: sources) |
||||
} |
||||
|
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in sources.filter({ $0.maleData == isMalePlayer() }) { |
||||
group.addTask { |
||||
guard !Task.isCancelled else { print("Cancelled"); return nil } |
||||
|
||||
return try? await source.first(where: { line in |
||||
line.rawValue.contains(";\(license);") |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if let first = await group.first(where: { $0 != nil }) { |
||||
group.cancelAll() |
||||
return first |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func historyFromName(from sources: [CSVParser]) async throws -> Line? { |
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in sources.filter({ $0.maleData == isMalePlayer() }) { |
||||
group.addTask { [lastName, firstName] in |
||||
guard !Task.isCancelled else { print("Cancelled"); return nil } |
||||
|
||||
return try? await source.first(where: { line in |
||||
line.rawValue.canonicalVersionWithPunctuation.contains(";\(lastName.canonicalVersionWithPunctuation);\(firstName.canonicalVersionWithPunctuation);") |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if let first = await group.first(where: { $0 != nil }) { |
||||
group.cancelAll() |
||||
return first |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func validateLicenceId(_ year: Int) { |
||||
if let currentLicenceId = licenceId { |
||||
if currentLicenceId.trimmed.hasSuffix("(\(year-1))") { |
||||
self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)") |
||||
} else if let computedLicense = currentLicenceId.strippedLicense { |
||||
self.licenceId = computedLicense + " (\(year))" |
||||
} |
||||
} |
||||
} |
||||
|
||||
func hasHomonym() -> Bool { |
||||
let federalContext = PersistenceController.shared.localContainer.viewContext |
||||
let fetchRequest = ImportedPlayer.fetchRequest() |
||||
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName) |
||||
fetchRequest.predicate = predicate |
||||
|
||||
do { |
||||
let count = try federalContext.count(for: fetchRequest) |
||||
return count > 1 |
||||
} catch { |
||||
|
||||
} |
||||
return false |
||||
} |
||||
|
||||
func hasInvalidLicence() -> Bool { |
||||
return (self.isImported() && self.isValidLicenseNumber(year: licenseYearValidity) == false) || |
||||
(self.isImported() == false && |
||||
(self.licenceId == nil || self.formattedLicense().isLicenseNumber == false || self.licenceId?.isEmpty == true)) |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
# Procédure d'ajout de champ dans une classe |
||||
|
||||
Dans Swift: |
||||
- Ajouter le champ dans classe |
||||
- Ajouter le champ dans le constructeur si possible |
||||
- Ajouter la codingKey correspondante |
||||
- Ajouter le champ dans l'encoding |
||||
- Ouvrir **ServerDataTests** et ajouter un test sur le champ |
||||
- Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple |
||||
|
||||
Dans Django: |
||||
- Ajouter le champ dans la classe |
||||
- S'il c'est un champ dans **CustomUser**: |
||||
- Ajouter le champ à la méthode fields_for_update |
||||
- Ajouter le champ dans UserSerializer > create > create_user dans serializers.py |
||||
- L'ajouter aussi dans admin.py si nécéssaire |
||||
- Faire le *makemigrations* + *migrate* |
||||
|
||||
|
||||
Enfin, revenir dans Xcode, ouvrir ServerDataTests et lancer le test mis à jour |
||||
|
||||
@ -0,0 +1,155 @@ |
||||
// |
||||
// Round+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension Round { |
||||
|
||||
static func setServerTitle(upperRound: Round, matchIndex: Int) -> String { |
||||
if upperRound.index == 0 { return upperRound.roundTitle() } |
||||
return upperRound.roundTitle() + " #" + (matchIndex + 1).formatted() |
||||
} |
||||
|
||||
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String { |
||||
if parent != nil { |
||||
if let seedInterval = seedInterval(initialMode: initialMode) { |
||||
return seedInterval.localizedLabel(displayStyle) |
||||
} |
||||
print("Round pas trouvé", id, parent, index) |
||||
return "Match de classement" |
||||
} |
||||
return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle) |
||||
} |
||||
|
||||
func pasteData() -> String { |
||||
var data: [String] = [] |
||||
data.append(self.roundTitle()) |
||||
|
||||
playedMatches().forEach { match in |
||||
data.append(match.matchTitle()) |
||||
data.append(match.team(.one)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----") |
||||
data.append(match.team(.two)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----") |
||||
} |
||||
|
||||
return data.joined(separator: "\n") |
||||
} |
||||
|
||||
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func correspondingLoserRoundTitle()", duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index) |
||||
let seedsAfterThisRound: [TeamRegistration] = self.tournamentStore.teamRegistrations.filter { |
||||
$0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex |
||||
} |
||||
|
||||
// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: { |
||||
// $0.tournament == tournament |
||||
// && $0.bracketPosition != nil |
||||
// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex |
||||
// }) |
||||
let playedMatches = playedMatches() |
||||
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count) |
||||
return seedInterval.localizedLabel(displayStyle) |
||||
} |
||||
|
||||
func buildLoserBracket() { |
||||
guard loserRounds().isEmpty else { return } |
||||
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index) |
||||
guard currentRoundMatchCount > 1 else { return } |
||||
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount) |
||||
|
||||
var loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat |
||||
if let parentRound { |
||||
loserBracketMatchFormat = tournamentObject()?.loserBracketSmartMatchFormat(parentRound.index) |
||||
} |
||||
|
||||
let rounds = (0..<roundCount).map { //index 0 is the final |
||||
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat) |
||||
round.parent = id //parent |
||||
return round |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount) |
||||
|
||||
let matches = (0..<matchCount).map { //0 is final match |
||||
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0) |
||||
let round = rounds[roundIndex] |
||||
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat, name: round.roundTitle(initialMode: true)) |
||||
//initial mode let the roundTitle give a name without considering the playable match |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
loserRounds().forEach { round in |
||||
round.buildLoserBracket() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Round: Selectable, Equatable { |
||||
static func == (lhs: Round, rhs: Round) -> Bool { |
||||
lhs.id == rhs.id |
||||
} |
||||
|
||||
|
||||
func selectionLabel(index: Int) -> String { |
||||
if let parentRound { |
||||
return "Tour #\(parentRound.loserRounds().count - index)" |
||||
} else { |
||||
return roundTitle(.short) |
||||
} |
||||
} |
||||
|
||||
func badgeValue() -> Int? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func badgeValue round of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
|
||||
if let parentRound { |
||||
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count |
||||
} else { |
||||
return playedMatches().filter({ $0.isRunning() }).count |
||||
} |
||||
} |
||||
|
||||
func badgeValueColor() -> Color? { |
||||
return nil |
||||
} |
||||
|
||||
func badgeImage() -> Badge? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func badgeImage of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return hasEnded() ? .checkmark : nil |
||||
} |
||||
} |
||||
@ -0,0 +1,20 @@ |
||||
// |
||||
// SeedInterval+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension SeedInterval { |
||||
|
||||
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
if dimension < 3 { |
||||
return "\(first)\(first.ordinalFormattedSuffix()) place" |
||||
} else { |
||||
return "Place \(first) à \(last)" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,109 @@ |
||||
// |
||||
// TeamRegistration+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension TeamRegistration { |
||||
|
||||
func getPhoneNumbers() -> [String] { |
||||
return players().compactMap { $0.phoneNumber }.filter({ $0.isMobileNumber() }) |
||||
} |
||||
|
||||
func playersPasteData(_ exportFormat: ExportFormat = .rawText) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return players().map { $0.pasteData(exportFormat) }.joined(separator: exportFormat.newLineSeparator()) |
||||
case .csv: |
||||
return players().map { [$0.pasteData(exportFormat), isWildCard() ? "WC" : $0.computedRank.formatted() ].joined(separator: exportFormat.separator()) }.joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
func formattedInscriptionDate(_ exportFormat: ExportFormat = .rawText) -> String? { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
if let registrationDate { |
||||
return "Inscrit le " + registrationDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
case .csv: |
||||
if let registrationDate { |
||||
return registrationDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func formattedSummonDate(_ exportFormat: ExportFormat = .rawText) -> String? { |
||||
|
||||
switch exportFormat { |
||||
case .rawText: |
||||
if let callDate { |
||||
return "Convoqué le " + callDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
case .csv: |
||||
if let callDate { |
||||
return callDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func pasteData(_ exportFormat: ExportFormat = .rawText, _ index: Int = 0) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return [playersPasteData(exportFormat), formattedInscriptionDate(exportFormat), name].compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator()) |
||||
case .csv: |
||||
return [index.formatted(), playersPasteData(exportFormat), isWildCard() ? "WC" : weight.formatted()].joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
func positionLabel() -> String? { |
||||
if groupStagePosition != nil { return "Poule" } |
||||
if let initialRound = initialRound() { |
||||
return initialRound.roundTitle() |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func initialRoundColor() -> Color? { |
||||
if walkOut { return Color.logoRed } |
||||
if groupStagePosition != nil { return Color.blue } |
||||
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] { |
||||
return Color(uiColor: .init(fromHex: colorHex)) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func containsExactlyPlayerLicenses(_ playerLicenses: [String?]) -> Bool { |
||||
let arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion }) |
||||
let ids : Set<String> = Set<String>(arrayOfIds.sorted()) |
||||
let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted()) |
||||
return ids.hashValue == searchedIds.hashValue |
||||
} |
||||
|
||||
@objc |
||||
var canonicalName: String { |
||||
players().map { $0.canonicalName }.joined(separator: " ") |
||||
} |
||||
|
||||
func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String { |
||||
return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ") |
||||
} |
||||
|
||||
func teamLabelRanked(displayRank: Bool, displayTeamName: Bool) -> String { |
||||
[displayTeamName ? name : nil, displayRank ? seedIndex() : nil, displayTeamName ? (name == nil ? teamLabel() : name) : teamLabel()].compactMap({ $0 }).joined(separator: " ") |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,395 @@ |
||||
// |
||||
// Tournament+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 27/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension Tournament { |
||||
|
||||
func shareURL(_ pageLink: PageLink = .matches) -> URL? { |
||||
if pageLink == .clubBroadcast { |
||||
let club = club() |
||||
print("club", club) |
||||
print("club broadcast code", club?.broadcastCode) |
||||
if let club, let broadcastCode = club.broadcastCode { |
||||
return URLs.main.url.appending(path: "c/\(broadcastCode)") |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
return URLs.main.url.appending(path: "tournament/\(id)").appending(path: pageLink.path) |
||||
} |
||||
|
||||
func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText) -> String { |
||||
let selectedSortedTeams = selectedSortedTeams() |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return (selectedSortedTeams.compactMap { $0.pasteData(exportFormat) } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true).compactMap { $0.pasteData(exportFormat) }).joined(separator: exportFormat.newLineSeparator(2)) |
||||
case .csv: |
||||
let headers = ["N°", "Nom Prénom", "rang", "Nom Prénom", "rang", "poids"].joined(separator: exportFormat.separator()) |
||||
var teamPaste = [headers] |
||||
for (index, team) in selectedSortedTeams.enumerated() { |
||||
teamPaste.append(team.pasteData(exportFormat, index + 1)) |
||||
} |
||||
return teamPaste.joined(separator: exportFormat.newLineSeparator()) |
||||
} |
||||
} |
||||
|
||||
func importTeams(_ teams: [FileImportManager.TeamHolder]) { |
||||
var teamsToImport = [TeamRegistration]() |
||||
let players = players().filter { $0.licenceId != nil } |
||||
teams.forEach { team in |
||||
if let previousTeam = team.previousTeam { |
||||
previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory) |
||||
teamsToImport.append(previousTeam) |
||||
} else { |
||||
var registrationDate = team.registrationDate |
||||
if let previousPlayer = players.first(where: { player in |
||||
let ids = team.players.compactMap({ $0.licenceId }) |
||||
return ids.contains(player.licenceId!) |
||||
}), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate { |
||||
registrationDate = previousTeamRegistrationDate |
||||
} |
||||
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name) |
||||
teamsToImport.append(newTeam) |
||||
} |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
do { |
||||
try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: teams.flatMap { $0.players }) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
|
||||
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty { |
||||
setGroupStage(randomize: groupStageSortMode == .random) |
||||
} |
||||
} |
||||
|
||||
func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] { |
||||
players.filter({ $0.hasHomonym() }) |
||||
} |
||||
|
||||
func finalRanking() async -> [Int: [String]] { |
||||
var teams: [Int: [String]] = [:] |
||||
var ids: Set<String> = Set<String>() |
||||
let rounds = rounds() |
||||
let final = rounds.last?.playedMatches().last |
||||
if let winner = final?.winningTeamId { |
||||
teams[1] = [winner] |
||||
ids.insert(winner) |
||||
} |
||||
if let finalist = final?.losingTeamId { |
||||
teams[2] = [finalist] |
||||
ids.insert(finalist) |
||||
} |
||||
|
||||
let others: [Round] = rounds.flatMap { round in |
||||
let losers = round.losers() |
||||
let minimumFinalPosition = round.seedInterval()?.last ?? teamCount |
||||
if teams[minimumFinalPosition] == nil { |
||||
teams[minimumFinalPosition] = losers.map { $0.id } |
||||
} else { |
||||
teams[minimumFinalPosition]?.append(contentsOf: losers.map { $0.id }) |
||||
} |
||||
|
||||
print("round", round.roundTitle()) |
||||
let rounds = round.loserRoundsAndChildren().filter { $0.isRankDisabled() == false && $0.hasNextRound() == false } |
||||
print(rounds.count, rounds.map { $0.roundTitle() }) |
||||
return rounds |
||||
}.compactMap({ $0 }) |
||||
|
||||
others.forEach { round in |
||||
print("round", round.roundTitle()) |
||||
if let interval = round.seedInterval() { |
||||
print("interval", interval.localizedLabel()) |
||||
let playedMatches = round.playedMatches().filter { $0.disabled == false || $0.isReady() } |
||||
print("playedMatches", playedMatches.count) |
||||
let winners = playedMatches.compactMap({ $0.winningTeamId }).filter({ ids.contains($0) == false }) |
||||
print("winners", winners.count) |
||||
let losers = playedMatches.compactMap({ $0.losingTeamId }).filter({ ids.contains($0) == false }) |
||||
print("losers", losers.count) |
||||
if winners.isEmpty { |
||||
let disabledIds = playedMatches.flatMap({ $0.teamScores.compactMap({ $0.teamRegistration }) }).filter({ ids.contains($0) == false }) |
||||
if disabledIds.isEmpty == false { |
||||
_removeStrings(from: &teams, stringsToRemove: disabledIds) |
||||
teams[interval.last] = disabledIds |
||||
let teamNames : [String] = disabledIds.compactMap { |
||||
let t : TeamRegistration? = Store.main.findById($0) |
||||
return t |
||||
}.map { $0.canonicalName } |
||||
print("winners.isEmpty", "\(interval.last) : ", teamNames) |
||||
disabledIds.forEach { |
||||
ids.insert($0) |
||||
} |
||||
} |
||||
} else { |
||||
if winners.isEmpty == false { |
||||
_removeStrings(from: &teams, stringsToRemove: winners) |
||||
teams[interval.first + winners.count - 1] = winners |
||||
let teamNames : [String] = winners.compactMap { |
||||
let t: TeamRegistration? = Store.main.findById($0) |
||||
return t |
||||
}.map { $0.canonicalName } |
||||
print("winners", "\(interval.last + winners.count - 1) : ", teamNames) |
||||
winners.forEach { ids.insert($0) } |
||||
} |
||||
|
||||
if losers.isEmpty == false { |
||||
_removeStrings(from: &teams, stringsToRemove: losers) |
||||
teams[interval.last] = losers |
||||
let loserTeamNames : [String] = losers.compactMap { |
||||
let t: TeamRegistration? = Store.main.findById($0) |
||||
return t |
||||
}.map { $0.canonicalName } |
||||
print("losers", "\(interval.last) : ", loserTeamNames) |
||||
losers.forEach { ids.insert($0) } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
let groupStages = groupStages() |
||||
let baseRank = teamCount - groupStageSpots() + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified |
||||
|
||||
groupStages.forEach { groupStage in |
||||
let groupStageTeams = groupStage.teams(true) |
||||
for (index, team) in groupStageTeams.enumerated() { |
||||
if team.qualified == false { |
||||
let groupStageWidth = max(((index == qualifiedPerGroupStage) ? groupStageCount - groupStageAdditionalQualified : groupStageCount) * (index - qualifiedPerGroupStage), 0) |
||||
|
||||
let _index = baseRank + groupStageWidth + 1 |
||||
if let existingTeams = teams[_index] { |
||||
teams[_index] = existingTeams + [team.id] |
||||
} else { |
||||
teams[_index] = [team.id] |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return teams |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Tournament: FederalTournamentHolder { |
||||
var holderId: String { id } |
||||
|
||||
func clubLabel() -> String { |
||||
locationLabel() |
||||
} |
||||
|
||||
func subtitleLabel() -> String { |
||||
subtitle() |
||||
} |
||||
|
||||
var tournaments: [any TournamentBuildHolder] { |
||||
[ |
||||
self |
||||
] |
||||
} |
||||
|
||||
func bracketStatus() async -> (status: String, description: String?, cut: TeamRegistration.TeamRange?) { |
||||
let availableSeeds = availableSeeds() |
||||
var description: String? = nil |
||||
if availableSeeds.isEmpty == false { |
||||
description = "placer \(availableSeeds.count) équipe\(availableSeeds.count.pluralSuffix)" |
||||
} |
||||
if description == nil { |
||||
let availableQualifiedTeams = availableQualifiedTeams() |
||||
if availableQualifiedTeams.isEmpty == false { |
||||
description = "placer \(availableQualifiedTeams.count) qualifié" + availableQualifiedTeams.count.pluralSuffix |
||||
} |
||||
} |
||||
|
||||
var cut: TeamRegistration.TeamRange? = nil |
||||
if description == nil && isAnimation() == false { |
||||
cut = TeamRegistration.TeamRange(availableSeeds.first, availableSeeds.last) |
||||
} |
||||
|
||||
if let round = getActiveRound() { |
||||
return ([round.roundTitle(.short), round.roundStatus()].joined(separator: " ").lowercased(), description, cut) |
||||
} else { |
||||
return ("", description, nil) |
||||
} |
||||
} |
||||
|
||||
func groupStageStatus() async -> (status: String, cut: TeamRegistration.TeamRange?) { |
||||
let groupStageTeams = groupStageTeams() |
||||
let groupStageTeamsCount = groupStageTeams.count |
||||
if groupStageTeamsCount == 0 || groupStageTeamsCount != groupStageSpots() { |
||||
return ("à compléter", nil) |
||||
} |
||||
|
||||
let cut : TeamRegistration.TeamRange? = isAnimation() ? nil : TeamRegistration.TeamRange(groupStageTeams.first, groupStageTeams.last) |
||||
|
||||
let runningGroupStages = groupStages().filter({ $0.isRunning() }) |
||||
if groupStagesAreOver() { return ("terminées", cut) } |
||||
if runningGroupStages.isEmpty { |
||||
|
||||
let ongoingGroupStages = runningGroupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }) |
||||
if ongoingGroupStages.isEmpty == false { |
||||
return ("Poule" + ongoingGroupStages.count.pluralSuffix + " " + ongoingGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) |
||||
} |
||||
return (groupStages().count.formatted() + " poule" + groupStages().count.pluralSuffix, cut) |
||||
} else { |
||||
return ("Poule" + runningGroupStages.count.pluralSuffix + " " + runningGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) |
||||
} |
||||
} |
||||
|
||||
func settingsDescriptionLocalizedLabel() -> String { |
||||
[courtCount.formatted() + " terrain\(courtCount.pluralSuffix)", entryFeeMessage].joined(separator: ", ") |
||||
} |
||||
|
||||
func structureDescriptionLocalizedLabel() -> String { |
||||
let groupStageLabel: String? = groupStageCount > 0 ? groupStageCount.formatted() + " poule\(groupStageCount.pluralSuffix)" : nil |
||||
return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") |
||||
} |
||||
|
||||
fileprivate func _paymentMethodMessage() -> String? { |
||||
return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods |
||||
} |
||||
|
||||
func updateRank(to newDate: Date?) async throws { |
||||
guard let newDate else { return } |
||||
rankSourceDate = newDate |
||||
|
||||
if currentMonthData() == nil { |
||||
let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate) |
||||
let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate) |
||||
await MainActor.run { |
||||
let formatted: String = URL.importDateFormatter.string(from: newDate) |
||||
let monthData: MonthData = MonthData(monthKey: formatted) |
||||
monthData.maleUnrankedValue = lastRankMan |
||||
monthData.femaleUnrankedValue = lastRankWoman |
||||
do { |
||||
try DataStore.shared.monthData.addOrUpdate(instance: monthData) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
let lastRankMan = currentMonthData()?.maleUnrankedValue |
||||
let lastRankWoman = currentMonthData()?.femaleUnrankedValue |
||||
let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate } |
||||
let sources = dataURLs.map { CSVParser(url: $0) } |
||||
|
||||
try await unsortedPlayers().concurrentForEach { player in |
||||
try await player.updateRank(from: sources, lastRank: (player.sex == .female ? lastRankWoman : lastRankMan) ?? 0) |
||||
} |
||||
} |
||||
|
||||
var entryFeeMessage: String { |
||||
if let entryFee { |
||||
let message: String = "Inscription: \(entryFee.formatted(.currency(code: "EUR"))) par joueur." |
||||
return [message, self._paymentMethodMessage()].compactMap { $0 }.joined(separator: "\n") |
||||
} else { |
||||
return "Inscription: gratuite." |
||||
} |
||||
} |
||||
|
||||
func deleteAndBuildEverything() { |
||||
resetBracketPosition() |
||||
deleteStructure() |
||||
deleteGroupStages() |
||||
buildGroupStages() |
||||
buildBracket() |
||||
} |
||||
|
||||
func buildBracket() { |
||||
guard rounds().isEmpty else { return } |
||||
let roundCount = RoundRule.numberOfRounds(forTeams: bracketTeamCount()) |
||||
|
||||
let rounds = (0..<roundCount).map { //index 0 is the final |
||||
return Round(tournament: id, index: $0, matchFormat: roundSmartMatchFormat($0)) |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
let matchCount = RoundRule.numberOfMatches(forTeams: bracketTeamCount()) |
||||
|
||||
let matches = (0..<matchCount).map { //0 is final match |
||||
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0) |
||||
let round = rounds[roundIndex] |
||||
return Match(round: round.id, index: $0, matchFormat: round.matchFormat, name: Match.setServerTitle(upperRound: round, matchIndex: RoundRule.matchIndexWithinRound(fromMatchIndex: $0))) |
||||
} |
||||
|
||||
print(matches.map { |
||||
(RoundRule.roundName(fromMatchIndex: $0.index), RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index)) |
||||
}) |
||||
|
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
rounds.forEach { round in |
||||
round.buildLoserBracket() |
||||
} |
||||
} |
||||
|
||||
func registrationIssues() -> Int { |
||||
let players : [PlayerRegistration] = unsortedPlayers() |
||||
let selectedTeams : [TeamRegistration] = selectedSortedTeams() |
||||
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) } |
||||
let duplicates : [PlayerRegistration] = duplicates(in: players) |
||||
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil }) |
||||
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players) |
||||
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players) |
||||
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) |
||||
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true) |
||||
let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil }) |
||||
let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil }) |
||||
|
||||
return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count |
||||
} |
||||
|
||||
func playersWithoutValidLicense(in players: [PlayerRegistration]) -> [PlayerRegistration] { |
||||
let licenseYearValidity = self.licenseYearValidity() |
||||
return players.filter { $0.hasInvalidLicence() } |
||||
} |
||||
|
||||
func missingUnrankedValue() -> Bool { |
||||
return maleUnrankedValue == nil || femaleUnrankedValue == nil |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Tournament { |
||||
static func newEmptyInstance() -> Tournament { |
||||
let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource |
||||
var _mostRecentDateAvailable: Date? { |
||||
guard let lastDataSource else { return nil } |
||||
return URL.importDateFormatter.date(from: lastDataSource) |
||||
} |
||||
|
||||
let rankSourceDate = _mostRecentDateAvailable |
||||
let tournaments : [Tournament] = DataStore.shared.tournaments.filter { $0.endDate != nil && $0.isDeleted == false } |
||||
let tournamentLevel = TournamentLevel.mostUsed(inTournaments: tournaments) |
||||
let tournamentCategory = TournamentCategory.mostUsed(inTournaments: tournaments) |
||||
let federalTournamentAge = FederalTournamentAge.mostUsed(inTournaments: tournaments) |
||||
//creator: DataStore.shared.user?.id |
||||
return Tournament(groupStageSortMode: .snake, rankSourceDate: rankSourceDate, teamSorting: tournamentLevel.defaultTeamSortingType, federalCategory: tournamentCategory, federalLevelCategory: tournamentLevel, federalAgeCategory: federalTournamentAge) |
||||
} |
||||
|
||||
static func fake() -> Tournament { |
||||
return Tournament(event: "Roland Garros", name: "Magic P100", startDate: Date(), endDate: Date(), creationDate: Date(), isPrivate: false, groupStageFormat: .nineGames, roundFormat: nil, loserRoundFormat: nil, groupStageSortMode: .snake, groupStageCount: 4, rankSourceDate: nil, dayDuration: 2, teamCount: 24, teamSorting: .rank, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .a45, closedRegistrationDate: nil, groupStageAdditionalQualified: 0, courtCount: 4, prioritizeClubMembers: false, qualifiedPerGroupStage: 2, teamsPerGroupStage: 4, entryFee: nil) |
||||
} |
||||
|
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,21 @@ |
||||
// |
||||
// CustomUser+Extensions.swift |
||||
// User+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// Created by Laurent Morvillier on 28/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension CustomUser { |
||||
|
||||
extension User { |
||||
|
||||
func currentPlayerData() -> ImportedPlayer? { |
||||
guard let licenceId = self.licenceId?.strippedLicense else { return nil } |
||||
guard let licenceId else { return nil } |
||||
let federalContext = PersistenceController.shared.localContainer.viewContext |
||||
let fetchRequest = ImportedPlayer.fetchRequest() |
||||
let predicate = NSPredicate(format: "license == %@", licenceId) |
||||
fetchRequest.predicate = predicate |
||||
return try? federalContext.fetch(fetchRequest).first |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,38 @@ |
||||
// |
||||
// Array+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 03/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Array where Element: Equatable { |
||||
|
||||
/// Remove first collection element that is equal to the given `object` or `element`: |
||||
mutating func remove(elements: [Element]) { |
||||
elements.forEach { |
||||
if let index = firstIndex(of: $0) { |
||||
remove(at: index) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension Array where Element: CustomStringConvertible { |
||||
func customJoined(separator: String, lastSeparator: String) -> String { |
||||
switch count { |
||||
case 0: |
||||
return "" |
||||
case 1: |
||||
return "\(self[0])" |
||||
case 2: |
||||
return "\(self[0]) \(lastSeparator) \(self[1])" |
||||
default: |
||||
let firstPart = dropLast().map { "\($0)" }.joined(separator: ", ") |
||||
let lastPart = "\(lastSeparator) \(last!)" |
||||
return "\(firstPart) \(lastPart)" |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,25 +0,0 @@ |
||||
// |
||||
// Badge+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
import PadelClubData |
||||
|
||||
extension Badge { |
||||
|
||||
func color() -> Color { |
||||
switch self { |
||||
case .checkmark: |
||||
.green |
||||
case .xmark: |
||||
.logoRed |
||||
case .custom(_, let color): |
||||
color |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
// |
||||
// Calendar+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 28/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Calendar { |
||||
func numberOfDaysBetween(_ from: Date?, and to: Date?) -> Int { |
||||
guard let from, let to else { return 0 } |
||||
let fromDate = startOfDay(for: from) |
||||
let toDate = startOfDay(for: to) |
||||
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) |
||||
|
||||
return numberOfDays.day! // <1> |
||||
} |
||||
|
||||
func isSameDay(date1: Date?, date2: Date?) -> Bool { |
||||
guard let date1, let date2 else { return false } |
||||
|
||||
return numberOfDaysBetween(date1, and: date2) == 0 |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,180 @@ |
||||
// |
||||
// Date+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 01/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum TimeOfDay { |
||||
case morning |
||||
case noon |
||||
case afternoon |
||||
case evening |
||||
case night |
||||
|
||||
var hello: String { |
||||
switch self { |
||||
case .morning, .noon, .afternoon: |
||||
return "Bonjour" |
||||
case .evening, .night: |
||||
return "Bonsoir" |
||||
} |
||||
} |
||||
|
||||
var goodbye: String { |
||||
switch self { |
||||
case .morning, .noon, .afternoon: |
||||
return "Bonne journée" |
||||
case .evening, .night: |
||||
return "Bonne soirée" |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Date { |
||||
func localizedDate() -> String { |
||||
self.formatted(.dateTime.weekday().day().month()) + " à " + self.formattedAsHourMinute() |
||||
} |
||||
|
||||
func formattedAsHourMinute() -> String { |
||||
formatted(.dateTime.hour().minute()) |
||||
} |
||||
|
||||
func formattedAsDate() -> String { |
||||
formatted(.dateTime.weekday().day(.twoDigits).month().year()) |
||||
} |
||||
|
||||
var monthYearFormatted: String { |
||||
formatted(.dateTime.month(.wide).year(.defaultDigits)) |
||||
} |
||||
|
||||
var twoDigitsYearFormatted: String { |
||||
formatted(Date.FormatStyle(date: .numeric, time: .omitted).locale(Locale(identifier: "fr_FR")).year(.twoDigits)) |
||||
} |
||||
|
||||
var timeOfDay: TimeOfDay { |
||||
let hour = Calendar.current.component(.hour, from: self) |
||||
switch hour { |
||||
case 6..<12 : return .morning |
||||
case 12 : return .noon |
||||
case 13..<17 : return .afternoon |
||||
case 17..<22 : return .evening |
||||
default: return .night |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Date { |
||||
|
||||
static var firstDayOfWeek = Calendar.current.firstWeekday |
||||
static var capitalizedFirstLettersOfWeekdays: [String] { |
||||
let calendar = Calendar.current |
||||
// let weekdays = calendar.shortWeekdaySymbols |
||||
|
||||
// return weekdays.map { weekday in |
||||
// guard let firstLetter = weekday.first else { return "" } |
||||
// return String(firstLetter).capitalized |
||||
// } |
||||
// Adjusted for the different weekday starts |
||||
var weekdays = calendar.veryShortStandaloneWeekdaySymbols |
||||
if firstDayOfWeek > 1 { |
||||
for _ in 1..<firstDayOfWeek { |
||||
if let first = weekdays.first { |
||||
weekdays.append(first) |
||||
weekdays.removeFirst() |
||||
} |
||||
} |
||||
} |
||||
return weekdays.map { $0.capitalized } |
||||
} |
||||
|
||||
static var fullMonthNames: [String] { |
||||
let dateFormatter = DateFormatter() |
||||
dateFormatter.locale = Locale.current |
||||
|
||||
return (1...12).compactMap { month in |
||||
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM") |
||||
let date = Calendar.current.date(from: DateComponents(year: 2000, month: month, day: 1)) |
||||
return date.map { dateFormatter.string(from: $0) } |
||||
} |
||||
} |
||||
|
||||
var startOfMonth: Date { |
||||
Calendar.current.dateInterval(of: .month, for: self)!.start |
||||
} |
||||
|
||||
var endOfMonth: Date { |
||||
let lastDay = Calendar.current.dateInterval(of: .month, for: self)!.end |
||||
return Calendar.current.date(byAdding: .day, value: -1, to: lastDay)! |
||||
} |
||||
|
||||
var startOfPreviousMonth: Date { |
||||
let dayInPreviousMonth = Calendar.current.date(byAdding: .month, value: -1, to: self)! |
||||
return dayInPreviousMonth.startOfMonth |
||||
} |
||||
|
||||
var numberOfDaysInMonth: Int { |
||||
Calendar.current.component(.day, from: endOfMonth) |
||||
} |
||||
|
||||
// var sundayBeforeStart: Date { |
||||
// let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth) |
||||
// let numberFromPreviousMonth = startOfMonthWeekday - 1 |
||||
// return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)! |
||||
// } |
||||
// New to accomodate for different start of week days |
||||
var firstWeekDayBeforeStart: Date { |
||||
let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth) |
||||
let numberFromPreviousMonth = startOfMonthWeekday - Self.firstDayOfWeek |
||||
return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)! |
||||
} |
||||
|
||||
var calendarDisplayDays: [Date] { |
||||
var days: [Date] = [] |
||||
// Current month days |
||||
for dayOffset in 0..<numberOfDaysInMonth { |
||||
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfMonth) |
||||
days.append(newDay!) |
||||
} |
||||
// previous month days |
||||
for dayOffset in 0..<startOfPreviousMonth.numberOfDaysInMonth { |
||||
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfPreviousMonth) |
||||
days.append(newDay!) |
||||
} |
||||
|
||||
// Fixed to accomodate different weekday starts |
||||
return days.filter { $0 >= firstWeekDayBeforeStart && $0 <= endOfMonth }.sorted(by: <) |
||||
} |
||||
|
||||
var monthInt: Int { |
||||
Calendar.current.component(.month, from: self) |
||||
} |
||||
|
||||
var yearInt: Int { |
||||
Calendar.current.component(.year, from: self) |
||||
} |
||||
|
||||
func endOfDay() -> Date { |
||||
let calendar = Calendar.current |
||||
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: self)! |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Date { |
||||
func localizedTime() -> String { |
||||
self.formattedAsHourMinute() |
||||
} |
||||
|
||||
func localizedDay() -> String { |
||||
self.formatted(.dateTime.weekday(.wide).day()) |
||||
} |
||||
|
||||
func localizedWeekDay() -> String { |
||||
self.formatted(.dateTime.weekday(.wide)) |
||||
} |
||||
} |
||||
@ -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 { |
||||
return self.formatted() + self.ordinalFormattedSuffix() |
||||
} |
||||
|
||||
var pluralSuffix: String { |
||||
return self > 1 ? "s" : "" |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
// |
||||
// Locale+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 03/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Locale { |
||||
|
||||
static func countries() -> [String] { |
||||
var countries: [String] = [] |
||||
|
||||
for countryCode in Locale.Region.isoRegions { |
||||
if let countryName = Locale.current.localizedString(forRegionCode: countryCode.identifier) { |
||||
countries.append(countryName) |
||||
} |
||||
} |
||||
|
||||
return countries.sorted() |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
// |
||||
// NumberFormatter+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 27/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension NumberFormatter { |
||||
static var ordinal: NumberFormatter { |
||||
let formatter = NumberFormatter() |
||||
formatter.numberStyle = .ordinal |
||||
return formatter |
||||
} |
||||
} |
||||
@ -1,230 +0,0 @@ |
||||
// |
||||
// PlayerRegistration+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension PlayerRegistration { |
||||
|
||||
convenience init(importedPlayer: ImportedPlayer) { |
||||
self.init() |
||||
self.teamRegistration = "" |
||||
self.firstName = (importedPlayer.firstName ?? "").prefixTrimmed(50).capitalized |
||||
self.lastName = (importedPlayer.lastName ?? "").prefixTrimmed(50).uppercased() |
||||
self.licenceId = importedPlayer.license?.prefixTrimmed(50) ?? nil |
||||
self.rank = Int(importedPlayer.rank) |
||||
self.sex = importedPlayer.male ? .male : .female |
||||
self.tournamentPlayed = importedPlayer.tournamentPlayed |
||||
self.points = importedPlayer.getPoints() |
||||
self.clubName = importedPlayer.clubName?.prefixTrimmed(200) |
||||
self.clubCode = importedPlayer.clubCode?.replaceCharactersFromSet(characterSet: .whitespaces).prefixTrimmed(20) |
||||
self.ligueName = importedPlayer.ligueName?.prefixTrimmed(200) |
||||
self.assimilation = importedPlayer.assimilation?.prefixTrimmed(50) |
||||
self.source = .frenchFederation |
||||
self.birthdate = importedPlayer.birthYear?.prefixTrimmed(50) |
||||
} |
||||
|
||||
convenience init?(federalData: [String], sex: Int, sexUnknown: Bool) { |
||||
self.init() |
||||
let _lastName = federalData[0].trimmed.uppercased() |
||||
let _firstName = federalData[1].trimmed.capitalized |
||||
if _lastName.isEmpty && _firstName.isEmpty { return nil } |
||||
lastName = _lastName.prefixTrimmed(50) |
||||
firstName = _firstName.prefixTrimmed(50) |
||||
birthdate = federalData[2].formattedAsBirthdate().prefixTrimmed(50) |
||||
licenceId = federalData[3].prefixTrimmed(50) |
||||
clubName = federalData[4].prefixTrimmed(200) |
||||
let stringRank = federalData[5] |
||||
if stringRank.isEmpty { |
||||
rank = nil |
||||
} else { |
||||
rank = Int(stringRank) |
||||
} |
||||
let _email = federalData[6] |
||||
if _email.isEmpty == false { |
||||
self.email = _email.prefixTrimmed(50) |
||||
} |
||||
let _phoneNumber = federalData[7] |
||||
if _phoneNumber.isEmpty == false { |
||||
self.phoneNumber = _phoneNumber.prefixTrimmed(50) |
||||
} |
||||
|
||||
source = .beachPadel |
||||
if sexUnknown { |
||||
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) { |
||||
self.sex = .female |
||||
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) { |
||||
self.sex = .male |
||||
} else { |
||||
self.sex = nil |
||||
} |
||||
} else { |
||||
self.sex = PlayerSexType(rawValue: sex) |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension PlayerRegistration { |
||||
|
||||
func hasHomonym() -> Bool { |
||||
let federalContext = PersistenceController.shared.localContainer.viewContext |
||||
let fetchRequest = ImportedPlayer.fetchRequest() |
||||
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName) |
||||
fetchRequest.predicate = predicate |
||||
|
||||
do { |
||||
let count = try federalContext.count(for: fetchRequest) |
||||
return count > 1 |
||||
} catch { |
||||
|
||||
} |
||||
return false |
||||
} |
||||
|
||||
func updateRank(from sources: [CSVParser], lastRank: Int?) async throws { |
||||
#if DEBUG_TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
if let dataFound = try await history(from: sources) { |
||||
rank = dataFound.rankValue?.toInt() |
||||
points = dataFound.points |
||||
tournamentPlayed = dataFound.tournamentCountValue?.toInt() |
||||
} else if let dataFound = try await historyFromName(from: sources) { |
||||
rank = dataFound.rankValue?.toInt() |
||||
points = dataFound.points |
||||
tournamentPlayed = dataFound.tournamentCountValue?.toInt() |
||||
} else { |
||||
rank = lastRank |
||||
} |
||||
} |
||||
|
||||
func history(from sources: [CSVParser]) async throws -> Line? { |
||||
#if DEBUG_TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func history()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
guard let license = licenceId?.strippedLicense else { |
||||
return nil // Do NOT call historyFromName here, let updateRank handle it |
||||
} |
||||
|
||||
let filteredSources = sources.filter { $0.maleData == isMalePlayer() } |
||||
|
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in filteredSources { |
||||
group.addTask { |
||||
guard !Task.isCancelled else { return nil } |
||||
return try? await source.first { $0.rawValue.contains(";\(license);") } |
||||
} |
||||
} |
||||
|
||||
for await result in group { |
||||
if let result { |
||||
group.cancelAll() // Stop other tasks as soon as we find a match |
||||
return result |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func historyFromName(from sources: [CSVParser]) async throws -> Line? { |
||||
#if DEBUG |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func historyFromName()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
let filteredSources = sources.filter { $0.maleData == isMalePlayer() } |
||||
let normalizedLastName = lastName.canonicalVersionWithPunctuation |
||||
let normalizedFirstName = firstName.canonicalVersionWithPunctuation |
||||
|
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in filteredSources { |
||||
group.addTask { |
||||
guard !Task.isCancelled else { print("Cancelled"); return nil } |
||||
return try? await source.first { |
||||
let lineValue = $0.rawValue.canonicalVersionWithPunctuation |
||||
return lineValue.contains(";\(normalizedLastName);\(normalizedFirstName);") |
||||
} |
||||
} |
||||
} |
||||
|
||||
for await result in group { |
||||
if let result { |
||||
group.cancelAll() // Stop other tasks as soon as we find a match |
||||
return result |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension PlayerRegistration: PlayerHolder { |
||||
|
||||
func getAssimilatedAsMaleRank() -> Int? { |
||||
nil |
||||
} |
||||
|
||||
func getFirstName() -> String { |
||||
firstName |
||||
} |
||||
|
||||
func getLastName() -> String { |
||||
lastName |
||||
} |
||||
|
||||
func getPoints() -> Double? { |
||||
self.points |
||||
} |
||||
|
||||
func getRank() -> Int? { |
||||
rank |
||||
} |
||||
|
||||
func isUnranked() -> Bool { |
||||
rank == nil |
||||
} |
||||
|
||||
func formattedRank() -> String { |
||||
self.rankLabel() |
||||
} |
||||
|
||||
func formattedLicense() -> String { |
||||
if let licenceId { return licenceId.computedLicense } |
||||
return "aucune licence" |
||||
} |
||||
|
||||
var male: Bool { |
||||
isMalePlayer() |
||||
} |
||||
|
||||
func getBirthYear() -> Int? { |
||||
nil |
||||
} |
||||
|
||||
func getProgression() -> Int { |
||||
0 |
||||
} |
||||
|
||||
func getComputedRank() -> Int? { |
||||
computedRank |
||||
} |
||||
} |
||||
@ -1,33 +0,0 @@ |
||||
// |
||||
// Round+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 30/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension Round { |
||||
|
||||
func loserBracketTurns() -> [LoserRound] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func loserBracketTurns()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
var rounds = [LoserRound]() |
||||
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index) |
||||
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount) |
||||
|
||||
for index in 0..<roundCount { |
||||
let lr = LoserRound(roundIndex: roundCount - index - 1, turnIndex: index, upperBracketRound: self) |
||||
rounds.append(lr) |
||||
} |
||||
|
||||
return rounds |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
// |
||||
// Sequence+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 03/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Sequence { |
||||
func pairs() -> AnySequence<(Element, Element)> { |
||||
AnySequence(zip(self, self.dropFirst())) |
||||
} |
||||
} |
||||
|
||||
extension Sequence { |
||||
func concurrentForEach( |
||||
_ operation: @escaping (Element) async throws -> Void |
||||
) async throws { |
||||
// A task group automatically waits for all of its |
||||
// sub-tasks to complete, while also performing those |
||||
// tasks in parallel: |
||||
try await withThrowingTaskGroup(of: Void.self) { group in |
||||
for element in self { |
||||
group.addTask { |
||||
try await operation(element) |
||||
} |
||||
|
||||
for try await _ in group {} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -1,34 +0,0 @@ |
||||
// |
||||
// SourceFileManager+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
extension SourceFileManager { |
||||
|
||||
func exportToCSV(_ prefix: String = "", players: [FederalPlayer], sourceFileType: SourceFile, date: Date) { |
||||
let lastDateString = URL.importDateFormatter.string(from: date) |
||||
let dateString = [prefix, "CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].filter({ $0.isEmpty == false }).joined(separator: "-") + "." + "csv" |
||||
|
||||
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! |
||||
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") |
||||
var csvText : String = "" |
||||
for player in players { |
||||
csvText.append(player.exportToCSV() + "\n") |
||||
} |
||||
|
||||
do { |
||||
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8) |
||||
print("CSV file exported successfully.") |
||||
} catch { |
||||
print("Error writing CSV file:", error) |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -1,42 +0,0 @@ |
||||
// |
||||
// SpinDrawable+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import PadelClubData |
||||
|
||||
extension String: SpinDrawable { |
||||
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] { |
||||
[self] |
||||
} |
||||
} |
||||
|
||||
extension Match: SpinDrawable { |
||||
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] { |
||||
let teams = teams() |
||||
if teams.count == 1, hideNames == false { |
||||
return teams.first!.segmentLabel(displayStyle, hideNames: hideNames) |
||||
} else { |
||||
return [roundTitle(), matchTitle(displayStyle)].compactMap { $0 } |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension TeamRegistration: SpinDrawable { |
||||
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] { |
||||
var strings: [String] = [] |
||||
let indexLabel = tournamentObject()?.labelIndexOf(team: self) |
||||
if let indexLabel { |
||||
strings.append(indexLabel) |
||||
if hideNames { |
||||
return strings |
||||
} |
||||
} |
||||
|
||||
strings.append(contentsOf: self.players().map { $0.playerLabel(displayStyle) }) |
||||
return strings |
||||
} |
||||
} |
||||
@ -0,0 +1,151 @@ |
||||
// |
||||
// String+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 01/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
// MARK: - Trimming and stuff |
||||
|
||||
extension String { |
||||
|
||||
func trunc(length: Int, trailing: String = "…") -> String { |
||||
return (self.count > length) ? self.prefix(length) + trailing : self |
||||
} |
||||
|
||||
func isValidEmail() -> Bool { |
||||
let emailRegEx = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$" |
||||
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx) |
||||
return emailPredicate.evaluate(with: self) |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - FFT License |
||||
extension String { |
||||
var computedLicense: String { |
||||
if let licenseKey { |
||||
return self + licenseKey |
||||
} else { |
||||
return self |
||||
} |
||||
} |
||||
|
||||
var strippedLicense: String? { |
||||
var dropFirst = 0 |
||||
if hasPrefix("0") { |
||||
dropFirst = 1 |
||||
} |
||||
if let match = self.dropFirst(dropFirst).firstMatch(of: /[0-9]{6,8}/) { |
||||
let lic = String(self.dropFirst(dropFirst)[match.range.lowerBound..<match.range.upperBound]) |
||||
return lic |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
var isLicenseNumber: Bool { |
||||
if let match = self.firstMatch(of: /[0-9]{6,8}[A-Z]/) { |
||||
let lic = String(self[match.range.lowerBound..<match.range.upperBound].dropLast(1)) |
||||
let lastLetter = String(self[match.range.lowerBound..<match.range.upperBound].suffix(1)) |
||||
|
||||
if let lkey = lic.licenseKey { |
||||
return lkey == lastLetter |
||||
} |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
var licenseKey: String? { |
||||
if let intValue = Int(self) { |
||||
var value = intValue |
||||
value -= 1 |
||||
value = value % 23 |
||||
let v = UnicodeScalar("A").value |
||||
let i = Int(v) |
||||
if let s = UnicodeScalar(i + value) { |
||||
var c = Character(s) |
||||
if c >= "I" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
if c >= "O" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
|
||||
if c >= "Q" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
return String(c) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func licencesFound() -> [String] { |
||||
let matches = self.matches(of: /[1-9][0-9]{5,7}/) |
||||
return matches.map { String(self[$0.range]) } |
||||
} |
||||
} |
||||
|
||||
// MARK: - FFT Source Importing |
||||
extension String { |
||||
enum RegexStatic { |
||||
static let mobileNumber = /^0[6-7]/ |
||||
//static let mobileNumber = /^(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})$/ |
||||
} |
||||
|
||||
func isMobileNumber() -> Bool { |
||||
firstMatch(of: RegexStatic.mobileNumber) != nil |
||||
} |
||||
|
||||
//april 04-2024 bug with accent characters / adobe / fft |
||||
mutating func replace(characters: [(Character, Character)]) { |
||||
for (targetChar, replacementChar) in characters { |
||||
self = String(self.map { $0 == targetChar ? replacementChar : $0 }) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// MARK: - Player Names |
||||
extension StringProtocol { |
||||
var firstUppercased: String { prefix(1).uppercased() + dropFirst() } |
||||
var firstCapitalized: String { prefix(1).capitalized + dropFirst() } |
||||
} |
||||
|
||||
// MARK: - todo clean up ?? |
||||
extension LosslessStringConvertible { |
||||
var string: String { .init(self) } |
||||
} |
||||
|
||||
extension String { |
||||
func createFile(_ withName: String = "temp", _ exportedFormat: ExportFormat = .rawText) -> URL { |
||||
let url = FileManager.default.temporaryDirectory |
||||
.appendingPathComponent(withName) |
||||
.appendingPathExtension(exportedFormat.suffix) |
||||
let string = self |
||||
try? FileManager.default.removeItem(at: url) |
||||
try? string.write(to: url, atomically: true, encoding: .utf8) |
||||
return url |
||||
} |
||||
} |
||||
|
||||
extension String { |
||||
func toInt() -> Int? { |
||||
Int(self) |
||||
} |
||||
} |
||||
@ -1,84 +0,0 @@ |
||||
// |
||||
// TeamRegistration+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
import PadelClubData |
||||
|
||||
extension TeamRegistration { |
||||
|
||||
func initialRoundColor() -> Color? { |
||||
if walkOut { return Color.logoRed } |
||||
if groupStagePosition != nil || wildCardGroupStage { return Color.blue } |
||||
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] { |
||||
return Color(uiColor: .init(fromHex: colorHex)) |
||||
} else if wildCardBracket { |
||||
return Color.mint |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func updateWeight(inTournamentCategory tournamentCategory: TournamentCategory) { |
||||
self.setWeight(from: self.players(), inTournamentCategory: tournamentCategory) |
||||
} |
||||
|
||||
func updatePlayers( |
||||
_ players: Set<PlayerRegistration>, |
||||
inTournamentCategory tournamentCategory: TournamentCategory |
||||
) { |
||||
let previousPlayers = Set(unsortedPlayers()) |
||||
|
||||
players.forEach { player in |
||||
previousPlayers.forEach { oldPlayer in |
||||
if player.licenceId?.strippedLicense == oldPlayer.licenceId?.strippedLicense, |
||||
player.licenceId?.strippedLicense != nil |
||||
{ |
||||
player.registeredOnline = oldPlayer.registeredOnline |
||||
if player.email?.canonicalVersion != oldPlayer.email?.canonicalVersion { |
||||
player.contactEmail = oldPlayer.email |
||||
} else { |
||||
player.contactEmail = oldPlayer.contactEmail |
||||
} |
||||
if areFrenchPhoneNumbersSimilar(player.phoneNumber, oldPlayer.phoneNumber) == false { |
||||
player.contactPhoneNumber = oldPlayer.phoneNumber |
||||
} else { |
||||
player.contactPhoneNumber = oldPlayer.contactPhoneNumber |
||||
} |
||||
player.contactName = oldPlayer.contactName |
||||
player.coach = oldPlayer.coach |
||||
player.tournamentPlayed = oldPlayer.tournamentPlayed |
||||
player.points = oldPlayer.points |
||||
player.captain = oldPlayer.captain |
||||
player.assimilation = oldPlayer.assimilation |
||||
player.ligueName = oldPlayer.ligueName |
||||
player.registrationStatus = oldPlayer.registrationStatus |
||||
player.timeToConfirm = oldPlayer.timeToConfirm |
||||
player.sex = oldPlayer.sex |
||||
player.paymentType = oldPlayer.paymentType |
||||
player.paymentId = oldPlayer.paymentId |
||||
player.clubMember = oldPlayer.clubMember |
||||
} |
||||
} |
||||
} |
||||
|
||||
let playersToRemove = previousPlayers.subtracting(players) |
||||
self.tournamentStore?.playerRegistrations.delete(contentOfs: Array(playersToRemove)) |
||||
setWeight(from: Array(players), inTournamentCategory: tournamentCategory) |
||||
|
||||
players.forEach { player in |
||||
player.teamRegistration = id |
||||
} |
||||
|
||||
// do { |
||||
// try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) |
||||
// } catch { |
||||
// Logger.error(error) |
||||
// } |
||||
} |
||||
|
||||
} |
||||
@ -1,428 +0,0 @@ |
||||
// |
||||
// Tournament+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 15/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
import PadelClubData |
||||
import LeStorage |
||||
|
||||
extension Tournament { |
||||
|
||||
func addTeam(_ players: Set<PlayerRegistration>, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration { |
||||
let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date(), name: name) |
||||
team.setWeight(from: Array(players), inTournamentCategory: tournamentCategory) |
||||
players.forEach { player in |
||||
player.teamRegistration = team.id |
||||
} |
||||
if isAnimation() { |
||||
if team.weight == 0 { |
||||
team.weight = unsortedTeams().count |
||||
} |
||||
} |
||||
return team |
||||
} |
||||
|
||||
func addWildCardIfNeeded(_ count: Int, _ type: MatchType) { |
||||
let currentCount = selectedSortedTeams().filter({ |
||||
if type == .bracket { |
||||
return $0.wildCardBracket |
||||
} else { |
||||
return $0.wildCardGroupStage |
||||
} |
||||
}).count |
||||
|
||||
if currentCount < count { |
||||
let _diff = count - currentCount |
||||
addWildCard(_diff, type) |
||||
} |
||||
} |
||||
|
||||
func addEmptyTeamRegistration(_ count: Int) { |
||||
|
||||
guard let tournamentStore = self.tournamentStore else { return } |
||||
|
||||
let teams = (0..<count).map { _ in |
||||
let team = TeamRegistration(tournament: id, registrationDate: Date()) |
||||
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory) |
||||
return team |
||||
} |
||||
|
||||
do { |
||||
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func addWildCard(_ count: Int, _ type: MatchType) { |
||||
let wcs = (0..<count).map { _ in |
||||
let team = TeamRegistration(tournament: id, registrationDate: Date()) |
||||
if type == .bracket { |
||||
team.wildCardBracket = true |
||||
} else { |
||||
team.wildCardGroupStage = true |
||||
} |
||||
|
||||
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory) |
||||
team.weight += 200_000 |
||||
return team |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: wcs) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func teamsRanked() -> [TeamRegistration] { |
||||
let selected = selectedSortedTeams().filter({ $0.finalRanking != nil }) |
||||
return selected.sorted(by: \.finalRanking!, order: .ascending) |
||||
} |
||||
|
||||
func playersWithoutValidLicense(in players: [PlayerRegistration], isImported: Bool) -> [PlayerRegistration] { |
||||
let licenseYearValidity = self.licenseYearValidity() |
||||
return players.filter({ player in |
||||
if player.isImported() { |
||||
// Player is marked as imported: check if the license is valid |
||||
return !player.isValidLicenseNumber(year: licenseYearValidity) |
||||
} else { |
||||
// Player is not imported: validate license and handle `isImported` flag for non-imported players |
||||
let noLicenseId = player.licenceId == nil || player.licenceId?.isEmpty == true |
||||
let invalidFormattedLicense = player.formattedLicense().isLicenseNumber == false |
||||
|
||||
// If global `isImported` is true, check license number as well |
||||
let invalidLicenseForImportedFlag = isImported && !player.isValidLicenseNumber(year: licenseYearValidity) |
||||
|
||||
return noLicenseId || invalidFormattedLicense || invalidLicenseForImportedFlag |
||||
} |
||||
}) |
||||
} |
||||
|
||||
func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] { |
||||
players.filter({ $0.hasHomonym() }) |
||||
} |
||||
|
||||
func payIfNecessary() async throws { |
||||
if self.payment != nil { return } |
||||
if let payment = await Guard.main.paymentForNewTournament() { |
||||
self.payment = payment |
||||
DataStore.shared.tournaments.addOrUpdate(instance: self) |
||||
return |
||||
} |
||||
throw PaymentError.cantPayTournament |
||||
} |
||||
|
||||
func cutLabelColor(index: Int?, teamCount: Int?) -> Color { |
||||
guard let index else { return Color.grayNotUniversal } |
||||
let _teamCount = teamCount ?? selectedSortedTeams().count |
||||
let groupStageCut = groupStageCut() |
||||
let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut) |
||||
if index < bracketCut { |
||||
return Color.mint |
||||
} else if index - bracketCut < groupStageCut && _teamCount > 0 { |
||||
return Color.indigo |
||||
} else { |
||||
return Color.grayNotUniversal |
||||
} |
||||
} |
||||
|
||||
func isPlayerAgeInadequate(player: PlayerHolder) -> Bool { |
||||
guard let computedAge = player.computedAge else { return false } |
||||
if federalTournamentAge.isAgeValid(age: computedAge) == false { |
||||
return true |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func isPlayerRankInadequate(player: PlayerHolder) -> Bool { |
||||
guard let rank = player.getRank() else { return false } |
||||
let _rank = player.male ? rank : rank + addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0) |
||||
if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge, seasonYear: startDate.seasonYear()) { |
||||
return true |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { |
||||
if startDate.isInCurrentYear() == false { |
||||
return [] |
||||
} |
||||
return players.filter { player in |
||||
return isPlayerRankInadequate(player: player) |
||||
} |
||||
} |
||||
|
||||
func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { |
||||
if startDate.isInCurrentYear() == false { |
||||
return [] |
||||
} |
||||
return players.filter { player in |
||||
return isPlayerAgeInadequate(player: player) |
||||
} |
||||
} |
||||
|
||||
func importTeams(_ teams: [FileImportManager.TeamHolder]) { |
||||
var teamsToImport = [TeamRegistration]() |
||||
let players = players().filter { $0.licenceId != nil } |
||||
teams.forEach { team in |
||||
if let previousTeam = team.previousTeam { |
||||
previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory) |
||||
teamsToImport.append(previousTeam) |
||||
} else { |
||||
var registrationDate = team.registrationDate |
||||
if let previousPlayer = players.first(where: { player in |
||||
let ids = team.players.compactMap({ $0.licenceId }) |
||||
return ids.contains(player.licenceId!) |
||||
}), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate { |
||||
registrationDate = previousTeamRegistrationDate |
||||
} |
||||
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name) |
||||
if isAnimation() { |
||||
if newTeam.weight == 0 { |
||||
newTeam.weight = team.index(in: teams) ?? 0 |
||||
} |
||||
} |
||||
teamsToImport.append(newTeam) |
||||
} |
||||
} |
||||
|
||||
if let tournamentStore = self.tournamentStore { |
||||
tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport) |
||||
let playersToImport = teams.flatMap { $0.players } |
||||
tournamentStore.playerRegistrations.addOrUpdate(contentOfs: playersToImport) |
||||
} |
||||
|
||||
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty { |
||||
setGroupStage(randomize: groupStageSortMode == .random) |
||||
} |
||||
} |
||||
|
||||
func registrationIssues(selectedTeams: [TeamRegistration]) async -> Int { |
||||
let players : [PlayerRegistration] = selectedTeams.flatMap { $0.players() } |
||||
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) } |
||||
let duplicates : [PlayerRegistration] = duplicates(in: players) |
||||
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil }) |
||||
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players) |
||||
let homonyms = homonyms(in: players) |
||||
let ageInadequatePlayers = ageInadequatePlayers(in: players) |
||||
let isImported = players.anySatisfy({ $0.isImported() }) |
||||
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players, isImported: isImported) |
||||
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) |
||||
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true) |
||||
let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil }) |
||||
let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil }) |
||||
|
||||
return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count + ageInadequatePlayers.count + homonyms.count |
||||
} |
||||
|
||||
func updateRank(to newDate: Date?, forceRefreshLockWeight: Bool, providedSources: [CSVParser]?) async throws { |
||||
refreshRanking = true |
||||
#if DEBUG_TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
guard let newDate else { return } |
||||
rankSourceDate = newDate |
||||
|
||||
// Fetch current month data only once |
||||
var monthData = currentMonthData() |
||||
|
||||
if monthData == nil { |
||||
async let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate) |
||||
async let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate) |
||||
|
||||
let formatted = URL.importDateFormatter.string(from: newDate) |
||||
let newMonthData = MonthData(monthKey: formatted) |
||||
|
||||
newMonthData.maleUnrankedValue = await lastRankMan |
||||
newMonthData.femaleUnrankedValue = await lastRankWoman |
||||
DataStore.shared.monthData.addOrUpdate(instance: newMonthData) |
||||
monthData = newMonthData |
||||
} |
||||
|
||||
let lastRankMan = monthData?.maleUnrankedValue |
||||
let lastRankWoman = monthData?.femaleUnrankedValue |
||||
|
||||
var chunkedParsers: [CSVParser] = [] |
||||
if let providedSources { |
||||
chunkedParsers = providedSources |
||||
} else { |
||||
// Fetch only the required files |
||||
let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate } |
||||
guard !dataURLs.isEmpty else { return } // Early return if no files found |
||||
|
||||
let sources = dataURLs.map { CSVParser(url: $0) } |
||||
chunkedParsers = try await chunkAllSources(sources: sources, size: 10000) |
||||
} |
||||
|
||||
let players = unsortedPlayers() |
||||
for player in players { |
||||
let lastRank = (player.sex == .female) ? lastRankWoman : lastRankMan |
||||
try await player.updateRank(from: chunkedParsers, lastRank: lastRank) |
||||
player.setComputedRank(in: self) |
||||
} |
||||
|
||||
if providedSources == nil { |
||||
try chunkedParsers.forEach { chunk in |
||||
try FileManager.default.removeItem(at: chunk.url) |
||||
} |
||||
} |
||||
|
||||
tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players) |
||||
|
||||
let unsortedTeams = unsortedTeams() |
||||
unsortedTeams.forEach { team in |
||||
team.setWeight(from: team.players(), inTournamentCategory: tournamentCategory) |
||||
if forceRefreshLockWeight { |
||||
team.lockedWeight = team.weight |
||||
} |
||||
} |
||||
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams) |
||||
refreshRanking = false |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Tournament { |
||||
static func newEmptyInstance() -> Tournament { |
||||
let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource |
||||
var _mostRecentDateAvailable: Date? { |
||||
guard let lastDataSource else { return nil } |
||||
return URL.importDateFormatter.date(from: lastDataSource) |
||||
} |
||||
|
||||
let rankSourceDate = _mostRecentDateAvailable |
||||
return Tournament(rankSourceDate: rankSourceDate, currencyCode: Locale.defaultCurrency()) |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Tournament: FederalTournamentHolder { |
||||
|
||||
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String { |
||||
if isAnimation() { |
||||
if let name { |
||||
return name.trunc(length: DeviceHelper.charLength()) |
||||
} else if build.age == .unlisted, build.category == .unlisted { |
||||
return build.level.localizedLevelLabel(.title) |
||||
} else { |
||||
return build.level.localizedLevelLabel(displayStyle) |
||||
} |
||||
} |
||||
return build.level.localizedLevelLabel(displayStyle) |
||||
} |
||||
|
||||
var codeClub: String? { |
||||
club()?.code |
||||
} |
||||
|
||||
var holderId: String { id } |
||||
|
||||
func clubLabel() -> String { |
||||
locationLabel() |
||||
} |
||||
|
||||
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String { |
||||
if isAnimation() { |
||||
if displayAgeAndCategory(forBuild: build) == false { |
||||
return [build.category.localizedCategoryLabel(ageCategory: build.age), build.age.localizedFederalAgeLabel()].filter({ $0.isEmpty == false }).joined(separator: " ") |
||||
} else if name != nil { |
||||
return build.level.localizedLevelLabel(.title) |
||||
} else { |
||||
return "" |
||||
} |
||||
} else { |
||||
return subtitle() |
||||
} |
||||
} |
||||
|
||||
var tournaments: [any TournamentBuildHolder] { |
||||
[ |
||||
self |
||||
] |
||||
} |
||||
|
||||
var dayPeriod: DayPeriod { |
||||
let day = startDate.get(.weekday) |
||||
switch day { |
||||
case 2...6: |
||||
return .week |
||||
default: |
||||
return .weekend |
||||
} |
||||
} |
||||
|
||||
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool { |
||||
if isAnimation() { |
||||
if let name, name.count < DeviceHelper.maxCharacter() { |
||||
return true |
||||
} else if build.age == .unlisted, build.category == .unlisted { |
||||
return true |
||||
} else { |
||||
return DeviceHelper.isBigScreen() |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
} |
||||
|
||||
extension Tournament: TournamentBuildHolder { |
||||
public func buildHolderTitle(_ displayStyle: DisplayStyle) -> String { |
||||
tournamentTitle(.short) |
||||
} |
||||
|
||||
public var category: TournamentCategory { |
||||
tournamentCategory |
||||
} |
||||
|
||||
public var level: TournamentLevel { |
||||
tournamentLevel |
||||
} |
||||
|
||||
public var age: FederalTournamentAge { |
||||
federalTournamentAge |
||||
} |
||||
} |
||||
|
||||
// MARK: - UI extensions |
||||
|
||||
extension Tournament { |
||||
|
||||
public var shouldShowPaymentInfo: Bool { |
||||
if self.payment != nil { |
||||
return false |
||||
} |
||||
switch self.state() { |
||||
case .initial, .build, .running: |
||||
return true |
||||
default: |
||||
return false |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
//extension Tournament { |
||||
// func deadline(for type: TournamentDeadlineType) -> Date? { |
||||
// guard [.p500, .p1000, .p1500, .p2000].contains(tournamentLevel) else { return nil } |
||||
// |
||||
// let daysOffset = type.daysOffset(level: tournamentLevel) |
||||
// if let date = Calendar.current.date(byAdding: .day, value: daysOffset, to: startDate) { |
||||
// let startOfDay = Calendar.current.startOfDay(for: date) |
||||
// return Calendar.current.date(byAdding: type.timeOffset, to: startOfDay) |
||||
// } |
||||
// return nil |
||||
// } |
||||
//} |
||||
@ -1,22 +0,0 @@ |
||||
// |
||||
// View+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 29/09/2025. |
||||
// |
||||
|
||||
import SwiftUI |
||||
|
||||
extension View { |
||||
/// Runs a transform only on iOS 26+, otherwise returns self |
||||
@ViewBuilder |
||||
func ifAvailableiOS26<Content: View>( |
||||
@ViewBuilder transform: (Self) -> Content |
||||
) -> some View { |
||||
if #available(iOS 26.0, *) { |
||||
transform(self) |
||||
} else { |
||||
self |
||||
} |
||||
} |
||||
} |
||||
@ -1,7 +1,4 @@ |
||||
<ul class="round"> |
||||
<li class="spacer" style="transform: translateY(-20px);"> |
||||
{{roundLabel}} |
||||
<div>{{formatLabel}}</div> |
||||
</li> |
||||
<li class="spacer"> {{roundLabel}}</li> |
||||
{{match-template}} |
||||
</ul> |
||||
|
||||
@ -1,14 +1,8 @@ |
||||
<li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}; position: relative;"> |
||||
<li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}"> |
||||
{{entrantOne}} |
||||
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionTop}}</div> |
||||
</li> |
||||
<li class="game game-spacer" style="visibility:{{hidden}}"> |
||||
<div class="center-match-overlay" style="visibility:{{hidden}};">{{centerMatchText}}</div> |
||||
</li> |
||||
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}; position: relative;"> |
||||
<div style="transform: translateY(-100%);"> |
||||
{{entrantTwo}} |
||||
</div> |
||||
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionBottom}}</div> |
||||
<li class="game game-spacer" style="visibility:{{hidden}}"><div class="multiline">{{matchDescription}}</div></li> |
||||
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}"> |
||||
{{entrantTwo}} |
||||
</li> |
||||
<li class="spacer"> </li> |
||||
|
||||
@ -1,4 +1,3 @@ |
||||
|
||||
<div class="player">{{teamIndex}}</div> |
||||
<div class="player">{{playerOne}}<span>{{weightOne}}</span></div> |
||||
<div class="player">{{playerTwo}}<span>{{weightTwo}}</span></div> |
||||
|
||||
@ -1,60 +0,0 @@ |
||||
// |
||||
// WaitingListView.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 26/02/2025. |
||||
// |
||||
|
||||
import SwiftUI |
||||
|
||||
struct WaitingListView: View { |
||||
@Environment(Tournament.self) var tournament: Tournament |
||||
let teamCount: Int |
||||
|
||||
@ViewBuilder |
||||
var body: some View { |
||||
Text("Attention, l'inscription en ligne est activée et vous avez des équipes inscrites en ligne, en modifiant la structure ces équipes seront intégrées ou retirées de votre sélection d'équipes. Pour l'instant Padel Club ne saura pas les prévenir automatiquement, vous devrez les contacter via l'écran de gestion des inscriptions.") |
||||
.foregroundStyle(.logoRed) |
||||
let selection = tournament.selectedSortedTeams() |
||||
if teamCount > tournament.teamCount { |
||||
Section { |
||||
let teams = tournament.waitingListSortedTeams(selectedSortedTeams: selection) |
||||
.prefix(teamCount - tournament.teamCount) |
||||
.filter { $0.hasRegisteredOnline() } |
||||
|
||||
ForEach(teams) { team in |
||||
NavigationLink { |
||||
EditingTeamView(team: team) |
||||
.environment(tournament) |
||||
} label: { |
||||
TeamRowView(team: team) |
||||
} |
||||
} |
||||
} header: { |
||||
Text("Équipes entrantes dans la sélection") |
||||
} footer: { |
||||
Text("Équipes inscrites en ligne à prévenir rentrant dans votre liste") |
||||
} |
||||
} |
||||
|
||||
if teamCount < tournament.teamCount { |
||||
Section { |
||||
let teams = selection.suffix(tournament.teamCount - teamCount) |
||||
.filter { $0.hasRegisteredOnline() } |
||||
|
||||
ForEach(teams) { team in |
||||
NavigationLink { |
||||
EditingTeamView(team: team) |
||||
.environment(tournament) |
||||
} label: { |
||||
TeamRowView(team: team) |
||||
} |
||||
} |
||||
} header: { |
||||
Text("Équipes sortantes de la sélection") |
||||
} footer: { |
||||
Text("Équipes inscrites en ligne à prévenir retirées de votre liste") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Binary file not shown.
@ -0,0 +1,224 @@ |
||||
// |
||||
// CloudConvert.swift |
||||
// Padel Tournament |
||||
// |
||||
// Created by Razmig Sarkissian on 14/09/2023. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class CloudConvert { |
||||
|
||||
enum CloudConvertionError: LocalizedError { |
||||
case unknownError |
||||
case serviceError(ErrorResponse) |
||||
case urlNotFound(String) |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .unknownError: |
||||
return "Erreur" |
||||
case .serviceError(let errorResponse): |
||||
return errorResponse.error |
||||
case .urlNotFound(let url): |
||||
return "L'URL [\(url)] n'est pas valide" |
||||
} |
||||
} |
||||
} |
||||
|
||||
static let manager = CloudConvert() |
||||
|
||||
func uploadFile(_ url: URL) async throws -> String { |
||||
let taskResponse = try await createJob(url) |
||||
let uploadResponse = try await uploadFile(taskResponse, url: url) |
||||
var fileReady = false |
||||
while fileReady == false { |
||||
try await Task.sleep(nanoseconds: 3_000_000_000) |
||||
let progressResponse = try await checkFile(id: uploadResponse.data.id) |
||||
if progressResponse.data.step == "finish" && progressResponse.data.stepPercent == 100 { |
||||
fileReady = true |
||||
print("progressResponse.data.minutes", progressResponse.data.minutes) |
||||
} |
||||
} |
||||
|
||||
let convertedFile = try await downloadConvertedFile(id: uploadResponse.data.id) |
||||
return convertedFile |
||||
} |
||||
|
||||
func createJob(_ url: URL) async throws -> TaskResponse { |
||||
guard let taskURL = URL(string: "https://api.convertio.co/convert") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert") |
||||
} |
||||
var request: URLRequest = URLRequest(url: taskURL) |
||||
let parameters = """ |
||||
{"apikey":"d97cf13ef6d163e5e386c381fc8d256f","input":"upload","file":"","filename":"","outputformat":"csv","options":""} |
||||
""" |
||||
|
||||
|
||||
let postData = parameters.data(using: .utf8) |
||||
request.httpMethod = "POST" |
||||
request.httpBody = postData |
||||
|
||||
let task = try await URLSession.shared.data(for: request) |
||||
//print("tried: \(request.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(TaskResponse.self, from: task.0) |
||||
} |
||||
|
||||
func uploadFile(_ response: TaskResponse, url: URL) async throws -> UploadResponse { |
||||
guard let uploadTaskURL = URL(string: "https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") |
||||
} |
||||
var uploadRequest: URLRequest = URLRequest(url: uploadTaskURL) |
||||
uploadRequest.httpMethod = "PUT" |
||||
let uploadTask = try await URLSession.shared.upload(for: uploadRequest, fromFile: url) |
||||
|
||||
//print("tried: \(uploadRequest.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: uploadTask.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(UploadResponse.self, from: uploadTask.0) |
||||
} |
||||
|
||||
func checkFile(id: String) async throws -> ProgressResponse { |
||||
guard let taskURL = URL(string: "https://api.convertio.co/convert/\(id)/status") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/status") |
||||
} |
||||
var request: URLRequest = URLRequest(url: taskURL) |
||||
request.httpMethod = "GET" |
||||
let task = try await URLSession.shared.data(for: request) |
||||
|
||||
//print("tried: \(request.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(ProgressResponse.self, from: task.0) |
||||
} |
||||
|
||||
func downloadConvertedFile(id: String) async throws -> String { |
||||
// try await Task.sleep(nanoseconds: 3_000_000_000) |
||||
|
||||
guard let downloadTaskURL = URL(string: "https://api.convertio.co/convert/\(id)/dl/base64") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/dl/base64") |
||||
} |
||||
var downloadRequest: URLRequest = URLRequest(url: downloadTaskURL) |
||||
downloadRequest.httpMethod = "GET" |
||||
let downloadTask = try await URLSession.shared.data(for: downloadRequest) |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: downloadTask.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
//print("tried: \(downloadRequest.url)") |
||||
let dataResponse = try JSONDecoder().decode(DataResponse.self, from: downloadTask.0) |
||||
if let decodedData = Data(base64Encoded: dataResponse.data.content), let string = String(data: decodedData, encoding: .utf8) { |
||||
return string |
||||
} |
||||
|
||||
throw CloudConvertionError.unknownError |
||||
} |
||||
} |
||||
|
||||
// MARK: - DataResponse |
||||
struct DataResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: DataDownloadClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct DataDownloadClass: Decodable { |
||||
let id, encode, content: String |
||||
} |
||||
|
||||
|
||||
// MARK: - ErrorResponse |
||||
struct ErrorResponse: Decodable { |
||||
let code: Int |
||||
let status, error: String |
||||
} |
||||
|
||||
|
||||
// MARK: - TaskResponse |
||||
struct TaskResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: DataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct DataClass: Decodable { |
||||
let id: String |
||||
} |
||||
|
||||
// MARK: - ProgressResponse |
||||
struct ProgressResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: ProgressDataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct ProgressDataClass: Decodable { |
||||
let id, step: String |
||||
let stepPercent: Int |
||||
let minutes: String |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case id, step |
||||
case stepPercent = "step_percent" |
||||
case minutes |
||||
} |
||||
|
||||
init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
id = try container.decode(String.self, forKey: .id) |
||||
step = try container.decode(String.self, forKey: .step) |
||||
minutes = try container.decode(String.self, forKey: .minutes) |
||||
if let value = try? container.decode(String.self, forKey: .stepPercent) { |
||||
print(value) |
||||
stepPercent = Int(value) ?? 0 |
||||
} else { |
||||
stepPercent = try container.decode(Int.self, forKey: .stepPercent) |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Output |
||||
struct Output: Decodable { |
||||
let url: String |
||||
let size: String |
||||
} |
||||
|
||||
// MARK: - UploadResponse |
||||
struct UploadResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: UploadDataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct UploadDataClass: Decodable { |
||||
let id, file: String |
||||
let size: Int |
||||
} |
||||
|
||||
extension URL { |
||||
var encodedLastPathComponent: String { |
||||
if #available(iOS 17.0, *) { |
||||
lastPathComponent |
||||
} else { |
||||
lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? lastPathComponent |
||||
} |
||||
|
||||
} |
||||
} |
||||
@ -0,0 +1,205 @@ |
||||
// |
||||
// ContactManager.swift |
||||
// Padel Tournament |
||||
// |
||||
// Created by Razmig Sarkissian on 19/09/2023. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
import MessageUI |
||||
import LeStorage |
||||
|
||||
enum ContactManagerError: LocalizedError { |
||||
case mailFailed |
||||
case mailNotSent //no network no error |
||||
case messageFailed |
||||
case messageNotSent //no network no error |
||||
} |
||||
|
||||
enum ContactType: Identifiable { |
||||
case mail(date: Date?, recipients: [String]?, bccRecipients: [String]?, body: String?, subject: String?, tournamentBuild: TournamentBuild?) |
||||
case message(date: Date?, recipients: [String]?, body: String?, tournamentBuild: TournamentBuild?) |
||||
|
||||
var id: Int { |
||||
switch self { |
||||
case .message: return 0 |
||||
case .mail: return 1 |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension ContactType { |
||||
static let defaultCustomMessage: String = "Il est conseillé de vous présenter 10 minutes avant de jouer.\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire." |
||||
static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces." |
||||
|
||||
static func callingCustomMessage(source: String? = nil, tournament: Tournament?, startDate: Date?, roundLabel: String) -> String { |
||||
let tournamentCustomMessage = source ?? DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage |
||||
let clubName = tournament?.clubName ?? "" |
||||
|
||||
var text = tournamentCustomMessage |
||||
let date = startDate ?? tournament?.startDate ?? Date() |
||||
|
||||
if let tournament { |
||||
text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.short)) |
||||
text = text.replacingOccurrences(of: "#prix", with: tournament.entryFeeMessage) |
||||
} |
||||
|
||||
text = text.replacingOccurrences(of: "#club", with: clubName) |
||||
text = text.replacingOccurrences(of: "#manche", with: roundLabel.lowercased()) |
||||
text = text.replacingOccurrences(of: "#jour", with: "\(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide)))") |
||||
text = text.replacingOccurrences(of: "#horaire", with: "\(date.formatted(Date.FormatStyle().hour().minute()))") |
||||
|
||||
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature() |
||||
|
||||
text = text.replacingOccurrences(of: "#signature", with: signature) |
||||
return text |
||||
} |
||||
|
||||
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, reSummon: Bool = false) -> String { |
||||
|
||||
let useFullCustomMessage = DataStore.shared.user.summonsUseFullCustomMessage |
||||
|
||||
if useFullCustomMessage { |
||||
return callingCustomMessage(tournament: tournament, startDate: startDate, roundLabel: roundLabel) |
||||
} |
||||
|
||||
let date = startDate ?? tournament?.startDate ?? Date() |
||||
|
||||
let clubName = tournament?.clubName ?? "" |
||||
let message = DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage |
||||
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature() |
||||
|
||||
let localizedCalled = "convoqué" + (tournament?.tournamentCategory == .women ? "e" : "") + "s" |
||||
|
||||
var entryFeeMessage: String? { |
||||
(DataStore.shared.user.summonsDisplayEntryFee) ? tournament?.entryFeeMessage : nil |
||||
} |
||||
|
||||
var computedMessage: String { |
||||
[entryFeeMessage, message].compacted().map { $0.trimmed }.joined(separator: "\n\n") |
||||
} |
||||
|
||||
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes" |
||||
|
||||
if let tournament { |
||||
return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)" |
||||
} else { |
||||
return "Bonjour,\n\n\(intro) \(localizedCalled) \(roundLabel) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire !\n\n\(signature)" |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
struct MessageComposeView: UIViewControllerRepresentable { |
||||
typealias Completion = (_ result: MessageComposeResult) -> Void |
||||
|
||||
static var canSendText: Bool { MFMessageComposeViewController.canSendText() } |
||||
|
||||
let recipients: [String]? |
||||
let body: String? |
||||
let completion: Completion? |
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { |
||||
guard Self.canSendText else { |
||||
let errorView = ContentUnavailableView("Aucun compte de messagerie", systemImage: "xmark", description: Text("Aucun compte de messagerie n'est configuré sur cet appareil.")) |
||||
return UIHostingController(rootView: errorView) |
||||
} |
||||
|
||||
let controller = MFMessageComposeViewController() |
||||
controller.messageComposeDelegate = context.coordinator |
||||
controller.recipients = recipients |
||||
controller.body = body |
||||
|
||||
return controller |
||||
} |
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} |
||||
|
||||
func makeCoordinator() -> Coordinator { |
||||
Coordinator(completion: self.completion) |
||||
} |
||||
|
||||
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate { |
||||
private let completion: Completion? |
||||
|
||||
public init(completion: Completion?) { |
||||
self.completion = completion |
||||
} |
||||
|
||||
public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) { |
||||
controller.dismiss(animated: true, completion: { |
||||
self.completion?(result) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
struct MailComposeView: UIViewControllerRepresentable { |
||||
typealias Completion = (_ result: MFMailComposeResult) -> Void |
||||
|
||||
static var canSendMail: Bool { |
||||
if let mailURL = URL(string: "mailto:?to=jap@padelclub.com") { |
||||
let mailConfigured = UIApplication.shared.canOpenURL(mailURL) |
||||
return mailConfigured && MFMailComposeViewController.canSendMail() |
||||
} else { |
||||
return MFMailComposeViewController.canSendMail() |
||||
} |
||||
} |
||||
|
||||
let recipients: [String]? |
||||
let bccRecipients: [String]? |
||||
let body: String? |
||||
let subject: String? |
||||
var attachmentURL: URL? |
||||
let completion: Completion? |
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { |
||||
guard Self.canSendMail else { |
||||
let errorView = ContentUnavailableView("Aucun compte mail", systemImage: "xmark", description: Text("Aucun compte mail n'est configuré sur cet appareil.")) |
||||
return UIHostingController(rootView: errorView) |
||||
} |
||||
|
||||
let controller = MFMailComposeViewController() |
||||
controller.mailComposeDelegate = context.coordinator |
||||
controller.setToRecipients(recipients) |
||||
controller.setBccRecipients(bccRecipients) |
||||
if let attachmentURL { |
||||
do { |
||||
let attachmentData = try Data(contentsOf: attachmentURL) |
||||
controller.addAttachmentData(attachmentData, mimeType: "application/zip", fileName: "backup.zip") |
||||
} catch { |
||||
print("Could not attach file: \(error)") |
||||
} |
||||
} |
||||
|
||||
if let body { |
||||
controller.setMessageBody(body, isHTML: false) |
||||
} |
||||
if let subject { |
||||
controller.setSubject(subject) |
||||
} |
||||
|
||||
return controller |
||||
} |
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} |
||||
|
||||
func makeCoordinator() -> Coordinator { |
||||
Coordinator(completion: self.completion) |
||||
} |
||||
|
||||
class Coordinator: NSObject, MFMailComposeViewControllerDelegate { |
||||
private let completion: Completion? |
||||
|
||||
public init(completion: Completion?) { |
||||
self.completion = completion |
||||
} |
||||
|
||||
public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { |
||||
controller.dismiss(animated: true, completion: { |
||||
self.completion?(result) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
// |
||||
// DisplayContext.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 20/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum DisplayContext { |
||||
case addition |
||||
case edition |
||||
case lockedForEditing |
||||
case selection |
||||
} |
||||
|
||||
enum MatchViewStyle { |
||||
case standardStyle // vue normal |
||||
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche |
||||
case feedStyle // vue programmation |
||||
case plainStyle // vue detail |
||||
case tournamentResultStyle //vue resultat tournoi |
||||
} |
||||
@ -0,0 +1,37 @@ |
||||
// |
||||
// ExportFormat.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 19/07/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum ExportFormat: Int, Identifiable, CaseIterable { |
||||
var id: Int { self.rawValue } |
||||
|
||||
case rawText |
||||
case csv |
||||
|
||||
var suffix: String { |
||||
switch self { |
||||
case .rawText: |
||||
return "txt" |
||||
case .csv: |
||||
return "csv" |
||||
} |
||||
} |
||||
|
||||
func separator() -> String { |
||||
switch self { |
||||
case .rawText: |
||||
return " " |
||||
case .csv: |
||||
return ";" |
||||
} |
||||
} |
||||
|
||||
func newLineSeparator(_ count: Int = 1) -> String { |
||||
return Array(repeating: "\n", count: count).joined() |
||||
} |
||||
} |
||||
@ -1,90 +0,0 @@ |
||||
// |
||||
// ConfigurationService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 14/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
class ConfigurationService { |
||||
static func fetchTournamentConfig() async throws -> TimeToConfirmConfig { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest(servicePath: "config/tournament/", method: .get, requiresToken: true) |
||||
let (data, _) = try await URLSession.shared.data(for: urlRequest) |
||||
return try JSONDecoder().decode(TimeToConfirmConfig.self, from: data) |
||||
} |
||||
|
||||
static func fetchPaymentConfig() async throws -> PaymentConfig { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest(servicePath: "config/payment/", method: .get, requiresToken: true) |
||||
let (data, _) = try await URLSession.shared.data(for: urlRequest) |
||||
return try JSONDecoder().decode(PaymentConfig.self, from: data) |
||||
} |
||||
} |
||||
|
||||
struct TimeToConfirmConfig: Codable { |
||||
let timeProximityRules: [String: Int] |
||||
let waitingListRules: [String: Int] |
||||
let businessRules: BusinessRules |
||||
let minimumResponseTime: Int |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case timeProximityRules = "time_proximity_rules" |
||||
case waitingListRules = "waiting_list_rules" |
||||
case businessRules = "business_rules" |
||||
case minimumResponseTime = "minimum_response_time" |
||||
} |
||||
|
||||
// Default configuration |
||||
static let defaultConfig = TimeToConfirmConfig( |
||||
timeProximityRules: [ |
||||
"24": 30, // within 24h → 30 min |
||||
"48": 60, // within 48h → 60 min |
||||
"72": 120, // within 72h → 120 min |
||||
"default": 240 |
||||
], |
||||
waitingListRules: [ |
||||
"30": 30, // 30+ teams → 30 min |
||||
"20": 60, // 20+ teams → 60 min |
||||
"10": 120, // 10+ teams → 120 min |
||||
"default": 240 |
||||
], |
||||
businessRules: BusinessRules( |
||||
hours: Hours( |
||||
start: 8, |
||||
end: 21 |
||||
) |
||||
), |
||||
minimumResponseTime: 30 |
||||
) |
||||
|
||||
} |
||||
|
||||
struct BusinessRules: Codable { |
||||
let hours: Hours |
||||
} |
||||
|
||||
struct Hours: Codable { |
||||
let start: Int |
||||
let end: Int |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case start |
||||
case end |
||||
} |
||||
|
||||
} |
||||
|
||||
struct PaymentConfig: Codable { |
||||
let stripeFee: Double |
||||
|
||||
// Default configuration |
||||
static let defaultConfig = PaymentConfig(stripeFee: 0.0075) |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case stripeFee = "stripe_fee" |
||||
} |
||||
|
||||
} |
||||
@ -1,302 +0,0 @@ |
||||
// |
||||
// FederalDataService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 09/07/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import CoreLocation |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
struct UmpireContactInfo: Codable { |
||||
let name: String? |
||||
let email: String? |
||||
let phone: String? |
||||
} |
||||
|
||||
/// Response model for the batch umpire data endpoint |
||||
struct UmpireDataResponse: Codable { |
||||
let results: [String: UmpireContactInfo] |
||||
} |
||||
|
||||
// New struct for the response from get_fft_club_tournaments and get_fft_all_tournaments |
||||
struct TournamentsAPIResponse: Codable { |
||||
let success: Bool |
||||
let tournaments: [FederalTournament] |
||||
let totalResults: Int |
||||
let currentCount: Int |
||||
let pagesScraped: Int? // Optional, as it might not always be present or relevant |
||||
let page: Int? // Optional, as it might not always be present or relevant |
||||
let umpireDataIncluded: Bool? // Only for get_fft_club_tournaments_with_umpire_data |
||||
let message: String |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case tournaments |
||||
case totalResults = "total_results" |
||||
case currentCount = "current_count" |
||||
case pagesScraped = "pages_scraped" |
||||
case page |
||||
case umpireDataIncluded = "umpire_data_included" |
||||
case message |
||||
} |
||||
} |
||||
|
||||
// MARK: - FederalDataService |
||||
|
||||
/// `FederalDataService` handles all API calls related to federal data (clubs, tournaments, umpire info). |
||||
/// All direct interactions with `tenup.fft.fr` are now assumed to be handled by your backend. |
||||
class FederalDataService { |
||||
static let shared: FederalDataService = FederalDataService() |
||||
|
||||
// The 'formId', 'tenupJsonDecoder', 'runTenupTask', and 'getNewBuildForm' |
||||
// from the legacy NetworkFederalService are removed as their logic is now |
||||
// handled server-side. |
||||
|
||||
/// Fetches federal clubs based on geographic criteria. |
||||
/// - Parameters: |
||||
/// - country: The country code (e.g., "fr"). |
||||
/// - city: The city name or address for search. |
||||
/// - radius: The search radius in kilometers. |
||||
/// - location: Optional `CLLocation` for user's precise position to calculate distance. |
||||
/// - Returns: A `FederalClubResponse` object containing a list of clubs and total count. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "country", value: country), |
||||
URLQueryItem(name: "city", value: city), |
||||
URLQueryItem(name: "radius", value: String(Int(radius))) |
||||
] |
||||
|
||||
if let location = location { |
||||
queryItems.append(URLQueryItem(name: "lat", value: location.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us"))))) |
||||
queryItems.append(URLQueryItem(name: "lng", value: location.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us"))))) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for federal clubs: 'fft/federal-clubs/' |
||||
let urlRequest = try service._baseRequest(servicePath: "fft/federal-clubs?\(queryString)", method: .get, requiresToken: false) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) // Keep URLError for generic network issues |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
return try JSONDecoder().decode(FederalClubResponse.self, from: data) |
||||
} catch { |
||||
print("Decoding error for FederalClubResponse: \(error)") |
||||
// Map decoding error to a generic API error |
||||
throw NetworkManagerError.apiError("Failed to decode FederalClubResponse: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches federal tournaments for a specific club. |
||||
/// This function now calls your backend, which in turn handles the `form_build_id` and pagination. |
||||
/// The `tournaments` parameter is maintained for signature compatibility but is not used for server-side fetching. |
||||
/// Client-side accumulation of results from multiple pages should be handled by the caller. |
||||
/// - Parameters: |
||||
/// - page: The current page number for pagination. |
||||
/// - tournaments: An array of already gathered tournaments (for signature compatibility; not used internally for fetching). |
||||
/// - club: The name of the club. |
||||
/// - codeClub: The unique code of the club. |
||||
/// - startDate: Optional start date for filtering tournaments. |
||||
/// - endDate: Optional end date for filtering tournaments. |
||||
/// - Returns: An array of `FederalTournament` objects for the requested page. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> TournamentsAPIResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "club_code", value: codeClub), |
||||
URLQueryItem(name: "club_name", value: club), |
||||
URLQueryItem(name: "page", value: String(page)) |
||||
] |
||||
|
||||
if let startDate = startDate { |
||||
queryItems.append(URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted)) |
||||
} |
||||
if let endDate = endDate { |
||||
queryItems.append(URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted)) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for club tournaments: 'fft/club-tournaments/' |
||||
let urlRequest = try service._baseRequest(servicePath: "fft/club-tournaments?\(queryString)", method: .get, requiresToken: false) |
||||
|
||||
print(urlRequest.url?.absoluteString) |
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
// Your backend should return a direct array of FederalTournament for the requested page |
||||
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data) |
||||
return federalTournaments |
||||
} catch { |
||||
print("Decoding error for FederalTournament array: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches all federal tournaments based on various filtering options. |
||||
/// This function now calls your backend, which handles the complex filtering and data retrieval. |
||||
/// The return type `[HttpCommand]` is maintained for signature compatibility, |
||||
/// wrapping the actual `[FederalTournament]` data within an `HttpCommand` structure. |
||||
/// - Parameters: |
||||
/// - sortingOption: How to sort the results (e.g., "dateDebut asc"). |
||||
/// - page: The current page number for pagination. |
||||
/// - startDate: The start date for the tournament search. |
||||
/// - endDate: The end date for the tournament search. |
||||
/// - city: The city to search within. |
||||
/// - distance: The search distance from the city. |
||||
/// - categories: An array of `TournamentCategory` to filter by. |
||||
/// - levels: An array of `TournamentLevel` to filter by. |
||||
/// - lat: Optional latitude for precise location search. |
||||
/// - lng: Optional longitude for precise location search. |
||||
/// - ages: An array of `FederalTournamentAge` to filter by. |
||||
/// - types: An array of `FederalTournamentType` to filter by. |
||||
/// - nationalCup: A boolean indicating if national cup tournaments should be included. |
||||
/// - Returns: An array of `HttpCommand` objects, containing the `FederalTournament` data. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getAllFederalTournaments( |
||||
sortingOption: String, |
||||
page: Int, |
||||
startDate: Date, |
||||
endDate: Date, |
||||
city: String, |
||||
distance: Double, |
||||
categories: [TournamentCategory], |
||||
levels: [TournamentLevel], |
||||
lat: String?, |
||||
lng: String?, |
||||
ages: [FederalTournamentAge], |
||||
types: [FederalTournamentType], |
||||
nationalCup: Bool |
||||
) async throws -> TournamentsAPIResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "sort", value: sortingOption), |
||||
URLQueryItem(name: "page", value: String(page)), |
||||
URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted), |
||||
URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted), |
||||
URLQueryItem(name: "city", value: city), |
||||
URLQueryItem(name: "distance", value: String(Int(distance))), |
||||
URLQueryItem(name: "national_cup", value: nationalCup ? "true" : "false") |
||||
] |
||||
|
||||
if let lat = lat, !lat.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "lat", value: lat)) |
||||
} |
||||
if let lng = lng, !lng.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "lng", value: lng)) |
||||
} |
||||
|
||||
// Add array parameters (assuming your backend can handle comma-separated or multiple query params) |
||||
if !categories.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "categories", value: categories.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
if !levels.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "levels", value: levels.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
if !ages.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "ages", value: ages.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
|
||||
if !types.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "types", value: types.map { $0.rawValue }.joined(separator: ","))) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for all tournaments: 'fft/all-tournaments/' |
||||
var urlRequest = try service._baseRequest(servicePath: "fft/all-tournaments?\(queryString)", method: .get, requiresToken: true) |
||||
urlRequest.timeoutInterval = 180 |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
print(urlRequest.url?.absoluteString ?? "No URL") |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
// Your backend should return a direct array of FederalTournament |
||||
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data) |
||||
return federalTournaments |
||||
} catch { |
||||
print("Decoding error for FederalTournament array in getAllFederalTournaments: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches umpire contact data for a given tournament ID. |
||||
/// This function now calls your backend, which performs the HTML scraping. |
||||
/// The return type is maintained for signature compatibility, mapping `UmpireContactInfo` to a tuple. |
||||
/// - Parameter idTournament: The ID of the tournament. |
||||
/// - Returns: A tuple `(name: String?, email: String?, phone: String?)` containing the umpire's contact info. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// The servicePath now points to your backend's endpoint for umpire data: 'fft/umpire/{tournament_id}/' |
||||
let servicePath = "fft/umpire/\(idTournament)/" |
||||
var urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false) |
||||
urlRequest.timeoutInterval = 120.0 |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
let umpireInfo = try JSONDecoder().decode(UmpireContactInfo.self, from: data) |
||||
// Map the decoded struct to the tuple required by the legacy signature |
||||
print(umpireInfo) |
||||
return (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone) |
||||
} catch { |
||||
print("Decoding error for UmpireContactInfo: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode UmpireContactInfo: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,28 @@ |
||||
// |
||||
// 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 |
||||
case fileNotModified |
||||
case fileNotDownloaded(Int) |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .maintenance: |
||||
return "Le site de la FFT est en maintenance" |
||||
default: |
||||
return String(describing: self) |
||||
} |
||||
} |
||||
} |
||||
@ -1,77 +0,0 @@ |
||||
// |
||||
// PaymentService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 01/10/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
class PaymentService { |
||||
static func resendPaymentEmail(teamRegistrationId: String) async throws -> SimpleResponse { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest( |
||||
servicePath: "resend-payment-email/\(teamRegistrationId)/", |
||||
method: .post, |
||||
requiresToken: true |
||||
) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, |
||||
httpResponse.statusCode == 200 else { |
||||
throw PaymentError.requestFailed |
||||
} |
||||
|
||||
return try JSON.decoder.decode(SimpleResponse.self, from: data) |
||||
} |
||||
|
||||
static func getPaymentLink(teamRegistrationId: String) async throws -> PaymentLinkResponse { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest( |
||||
servicePath: "payment-link/\(teamRegistrationId)/", |
||||
method: .get, |
||||
requiresToken: true |
||||
) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, |
||||
httpResponse.statusCode == 200 else { |
||||
throw PaymentError.requestFailed |
||||
} |
||||
|
||||
// // Debug: Print the raw JSON response |
||||
// if let jsonString = String(data: data, encoding: .utf8) { |
||||
// print("Raw JSON Response: \(jsonString)") |
||||
// } |
||||
|
||||
return try JSON.decoder.decode(PaymentLinkResponse.self, from: data) |
||||
} |
||||
|
||||
} |
||||
|
||||
struct PaymentLinkResponse: Codable { |
||||
let success: Bool |
||||
let paymentLink: String? |
||||
let message: String? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case paymentLink |
||||
case message |
||||
} |
||||
} |
||||
|
||||
enum PaymentError: Error { |
||||
case requestFailed |
||||
case unauthorized |
||||
case unknown |
||||
} |
||||
|
||||
struct SimpleResponse: Codable { |
||||
let success: Bool |
||||
let message: String |
||||
} |
||||
@ -1,50 +0,0 @@ |
||||
// |
||||
// RefundService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 11/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
class RefundService { |
||||
static func processRefund(teamRegistrationId: String) async throws -> RefundResponse { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest(servicePath: "refund-tournament/\(teamRegistrationId)/", method: .post, requiresToken: true) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, |
||||
httpResponse.statusCode == 200 else { |
||||
throw RefundError.requestFailed |
||||
} |
||||
|
||||
let refundResponse = try JSON.decoder.decode(RefundResponse.self, from: data) |
||||
return refundResponse |
||||
} |
||||
} |
||||
|
||||
struct RefundResponse: Codable { |
||||
let success: Bool |
||||
let message: String |
||||
let players: [PlayerRegistration]? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case message |
||||
case players |
||||
} |
||||
} |
||||
|
||||
enum RefundError: Error { |
||||
case requestFailed |
||||
case unauthorized |
||||
case unknown |
||||
} |
||||
|
||||
struct RefundResult { |
||||
let team: TeamRegistration |
||||
let response: Result<RefundResponse, Error> |
||||
} |
||||
@ -1,207 +0,0 @@ |
||||
// |
||||
// StripeValidationService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 12/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
class StripeValidationService { |
||||
|
||||
// MARK: - Validate Stripe Account |
||||
static func validateStripeAccount(accountId: String) async throws -> ValidationResponse { |
||||
let service = try StoreCenter.main.service() |
||||
var urlRequest = try service._baseRequest(servicePath: "validate-stripe-account/", method: .post, requiresToken: true) |
||||
|
||||
var body: [String: Any] = [:] |
||||
|
||||
body["account_id"] = accountId |
||||
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) |
||||
|
||||
do { |
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
|
||||
switch httpResponse.statusCode { |
||||
case 200...299: |
||||
let decodedResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) |
||||
return decodedResponse |
||||
case 400, 403, 404: |
||||
// Handle client errors - still decode as ValidationResponse |
||||
let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) |
||||
return errorResponse |
||||
default: |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
} catch let error as ValidationError { |
||||
throw error |
||||
} catch { |
||||
throw ValidationError.networkError(error) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Create Stripe Connect Account |
||||
static func createStripeConnectAccount() async throws -> CreateAccountResponse { |
||||
let service = try StoreCenter.main.service() |
||||
let urlRequest = try service._baseRequest(servicePath: "stripe/create-account/", method: .post, requiresToken: true) |
||||
do { |
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
|
||||
switch httpResponse.statusCode { |
||||
case 200...299: |
||||
let decodedResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) |
||||
return decodedResponse |
||||
case 400, 403, 404: |
||||
let errorResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) |
||||
return errorResponse |
||||
default: |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
} catch let error as ValidationError { |
||||
throw error |
||||
} catch { |
||||
throw ValidationError.networkError(error) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Create Stripe Account Link |
||||
static func createStripeAccountLink(_ accountId: String? = nil) async throws -> CreateLinkResponse { |
||||
let service = try StoreCenter.main.service() |
||||
var urlRequest = try service._baseRequest(servicePath: "stripe/create-account-link/", method: .post, requiresToken: true) |
||||
|
||||
var body: [String: Any] = [:] |
||||
|
||||
if let accountId = accountId { |
||||
body["account_id"] = accountId |
||||
} |
||||
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) |
||||
|
||||
do { |
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
|
||||
switch httpResponse.statusCode { |
||||
case 200...299: |
||||
let decodedResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) |
||||
return decodedResponse |
||||
case 400, 403, 404: |
||||
let errorResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) |
||||
return errorResponse |
||||
default: |
||||
throw ValidationError.invalidResponse |
||||
} |
||||
} catch let error as ValidationError { |
||||
throw error |
||||
} catch { |
||||
throw ValidationError.networkError(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// MARK: - Response Models |
||||
|
||||
struct ValidationResponse: Codable { |
||||
let valid: Bool |
||||
let canProcessPayments: Bool? |
||||
let onboardingComplete: Bool? |
||||
let needsOnboarding: Bool? |
||||
let account: AccountDetails? |
||||
let error: String? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case valid |
||||
case canProcessPayments = "can_process_payments" |
||||
case onboardingComplete = "onboarding_complete" |
||||
case needsOnboarding = "needs_onboarding" |
||||
case account |
||||
case error |
||||
} |
||||
} |
||||
|
||||
struct AccountDetails: Codable { |
||||
let id: String |
||||
let chargesEnabled: Bool? |
||||
let payoutsEnabled: Bool? |
||||
let detailsSubmitted: Bool? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case id |
||||
case chargesEnabled = "charges_enabled" |
||||
case payoutsEnabled = "payouts_enabled" |
||||
case detailsSubmitted = "details_submitted" |
||||
} |
||||
} |
||||
|
||||
struct CreateAccountResponse: Codable { |
||||
let success: Bool |
||||
let accountId: String? |
||||
let message: String? |
||||
let existing: Bool? |
||||
let error: String? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case accountId = "account_id" |
||||
case message |
||||
case existing |
||||
case error |
||||
} |
||||
} |
||||
|
||||
struct CreateLinkResponse: Codable { |
||||
let success: Bool |
||||
let url: URL? |
||||
let accountId: String? |
||||
let error: String? |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case url |
||||
case accountId = "account_id" |
||||
case error |
||||
} |
||||
} |
||||
|
||||
enum ValidationError: Error { |
||||
case invalidResponse |
||||
case networkError(Error) |
||||
case invalidData |
||||
case encodingError |
||||
case urlNotFound |
||||
case accountNotFound |
||||
case onlinePaymentNotEnabled |
||||
|
||||
var localizedDescription: String { |
||||
switch self { |
||||
case .invalidResponse: |
||||
return "Réponse du serveur invalide" |
||||
case .networkError(let error): |
||||
return "Erreur réseau : \(error.localizedDescription)" |
||||
case .invalidData: |
||||
return "Données reçues invalides" |
||||
case .encodingError: |
||||
return "Échec de l'encodage des données de la requête" |
||||
case .accountNotFound: |
||||
return "Le compte n'a pas pu être généré" |
||||
case .urlNotFound: |
||||
return "Le lien pour utiliser un compte stripe n'a pas pu être généré" |
||||
case .onlinePaymentNotEnabled: |
||||
return "Le paiement en ligne n'a pas pu être activé pour ce tournoi" |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,83 +0,0 @@ |
||||
// |
||||
// XlsToCsvService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 12/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
class XlsToCsvService { |
||||
|
||||
static func exportToCsv(url: URL) async throws -> String { |
||||
let service = try StoreCenter.main.service() |
||||
var request = try service._baseRequest(servicePath: "xls-to-csv/", method: .post, requiresToken: true) |
||||
|
||||
// Create the boundary string for multipart/form-data |
||||
let boundary = UUID().uuidString |
||||
|
||||
// Set the content type to multipart/form-data with the boundary |
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") |
||||
|
||||
// The file to upload |
||||
let fileName = url.lastPathComponent |
||||
let fileURL = url |
||||
|
||||
// Construct the body of the request |
||||
var body = Data() |
||||
|
||||
// Start the body with the boundary and content-disposition for the file |
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!) |
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) |
||||
body.append("Content-Type: application/vnd.ms-excel\r\n\r\n".data(using: .utf8)!) |
||||
|
||||
// Append the file data |
||||
if let fileData = try? Data(contentsOf: fileURL) { |
||||
body.append(fileData) |
||||
} |
||||
|
||||
// End the body with the boundary |
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) |
||||
|
||||
// Set the body of the request |
||||
request.httpBody = body |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
|
||||
// Check the response status code |
||||
if let httpResponse = response as? HTTPURLResponse { |
||||
print("Status code: \(httpResponse.statusCode)") |
||||
} |
||||
|
||||
// Convert the response data to a String |
||||
if let responseString = String(data: data, encoding: .utf8) { |
||||
return responseString |
||||
} else { |
||||
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide") |
||||
throw ConvertionError.serviceError(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
struct ErrorResponse: Decodable { |
||||
let code: Int |
||||
let status, error: String |
||||
} |
||||
|
||||
enum ConvertionError: LocalizedError { |
||||
case unknownError |
||||
case serviceError(ErrorResponse) |
||||
case urlNotFound(String) |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .unknownError: |
||||
return "Erreur" |
||||
case .serviceError(let errorResponse): |
||||
return errorResponse.error |
||||
case .urlNotFound(let url): |
||||
return "L'URL [\(url)] n'est pas valide" |
||||
} |
||||
} |
||||
} |
||||
@ -1,59 +0,0 @@ |
||||
import Foundation |
||||
|
||||
func areFrenchPhoneNumbersSimilar(_ phoneNumber1: String?, _ phoneNumber2: String?) -> Bool { |
||||
|
||||
if phoneNumber1?.canonicalVersion == phoneNumber2?.canonicalVersion { |
||||
return true |
||||
} |
||||
|
||||
// Helper function to normalize a phone number, now returning an optional String |
||||
func normalizePhoneNumber(_ numberString: String?) -> String? { |
||||
// 1. Safely unwrap the input string. If it's nil or empty, return nil immediately. |
||||
guard let numberString = numberString, !numberString.isEmpty else { |
||||
return nil |
||||
} |
||||
|
||||
// 2. Remove all non-digit characters |
||||
let digitsOnly = numberString.filter(\.isNumber) |
||||
|
||||
// If after filtering, there are no digits, return nil. |
||||
guard !digitsOnly.isEmpty else { |
||||
return nil |
||||
} |
||||
|
||||
// 3. Handle French specific prefixes and extract the relevant part |
||||
// We need at least 9 digits to get a meaningful 8-digit comparison from the end |
||||
if digitsOnly.count >= 9 { |
||||
if digitsOnly.hasPrefix("0") { |
||||
return String(digitsOnly.suffix(9)) |
||||
} else if digitsOnly.hasPrefix("33") { |
||||
// Ensure there are enough digits after dropping "33" |
||||
if digitsOnly.count >= 11 { // "33" + 9 digits = 11 |
||||
return String(digitsOnly.dropFirst(2).suffix(9)) |
||||
} else { |
||||
return nil // Not enough digits after dropping "33" |
||||
} |
||||
} else if digitsOnly.count == 9 { // Case like 612341234 |
||||
return digitsOnly |
||||
} else { // More digits but no 0 or 33 prefix, take the last 9 |
||||
return String(digitsOnly.suffix(9)) |
||||
} |
||||
} |
||||
|
||||
return nil // If it doesn't fit the expected patterns or is too short |
||||
} |
||||
|
||||
// Normalize both phone numbers. If either results in nil, we can't compare. |
||||
guard let normalizedNumber1 = normalizePhoneNumber(phoneNumber1), |
||||
let normalizedNumber2 = normalizePhoneNumber(phoneNumber2) else { |
||||
return false |
||||
} |
||||
|
||||
// Ensure both normalized numbers have at least 8 digits before comparing suffixes |
||||
guard normalizedNumber1.count >= 8 && normalizedNumber2.count >= 8 else { |
||||
return false // One or both numbers are too short to have 8 comparable digits |
||||
} |
||||
|
||||
// Compare the last 8 digits |
||||
return normalizedNumber1.suffix(8) == normalizedNumber2.suffix(8) |
||||
} |
||||
@ -0,0 +1,257 @@ |
||||
// |
||||
// SourceFileManager.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 01/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
class SourceFileManager { |
||||
static let shared = SourceFileManager() |
||||
|
||||
init() { |
||||
createDirectoryIfNeeded() |
||||
} |
||||
|
||||
let rankingSourceDirectory : URL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "rankings") |
||||
|
||||
func createDirectoryIfNeeded() { |
||||
let fileManager = FileManager.default |
||||
do { |
||||
let directoryURL = rankingSourceDirectory |
||||
|
||||
// Check if the directory exists |
||||
if !fileManager.fileExists(atPath: directoryURL.path) { |
||||
// Directory does not exist, create it |
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) |
||||
print("Directory created at: \(directoryURL)") |
||||
} else { |
||||
print("Directory already exists at: \(directoryURL)") |
||||
} |
||||
} catch { |
||||
print("Error: \(error)") |
||||
} |
||||
} |
||||
|
||||
var lastDataSource: String? { |
||||
DataStore.shared.appSettings.lastDataSource |
||||
} |
||||
|
||||
func lastDataSourceDate() -> Date? { |
||||
guard let lastDataSource else { return nil } |
||||
return URL.importDateFormatter.date(from: lastDataSource) |
||||
} |
||||
|
||||
func fetchData() async { |
||||
await fetchData(fromDate: Date()) |
||||
// if let mostRecent = mostRecentDateAvailable, let current = Calendar.current.date(byAdding: .month, value: 1, to: mostRecent), current > mostRecent { |
||||
// await fetchData(fromDate: current) |
||||
// } else { |
||||
// } |
||||
} |
||||
|
||||
func _removeAllData(fromDate current: Date) { |
||||
let lastStringDate = URL.importDateFormatter.string(from: current) |
||||
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"] |
||||
files.forEach { fileName in |
||||
NetworkManager.shared.removeRankingData(lastDateString: lastStringDate, fileName: fileName) |
||||
} |
||||
} |
||||
|
||||
func exportToCSV(players: [FederalPlayer], sourceFileType: SourceFile, date: Date) { |
||||
let lastDateString = URL.importDateFormatter.string(from: date) |
||||
let dateString = ["CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].joined(separator: "-") + "." + "csv" |
||||
|
||||
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! |
||||
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") |
||||
var csvText : String = "" |
||||
for player in players { |
||||
csvText.append(player.exportToCSV() + "\n") |
||||
} |
||||
|
||||
do { |
||||
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8) |
||||
print("CSV file exported successfully.") |
||||
} catch { |
||||
print("Error writing CSV file:", error) |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
actor SourceFileDownloadTracker { |
||||
var _downloadedFileStatus : Int? = nil |
||||
|
||||
func updateIfNecessary(with successState: Int?) { |
||||
if successState != nil && (_downloadedFileStatus == nil || _downloadedFileStatus == 0) { |
||||
_downloadedFileStatus = successState |
||||
} |
||||
} |
||||
|
||||
func getDownloadedFileStatus() -> Int? { |
||||
return _downloadedFileStatus |
||||
} |
||||
|
||||
} |
||||
|
||||
//return nil if no new files |
||||
//return 1 if new file to import |
||||
//return 0 if new file just to re-calc static data, no need to re-import |
||||
@discardableResult |
||||
func fetchData(fromDate current: Date) async -> Int? { |
||||
let lastStringDate = URL.importDateFormatter.string(from: current) |
||||
|
||||
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"] |
||||
|
||||
let sourceFileDownloadTracker = SourceFileDownloadTracker() |
||||
|
||||
do { |
||||
try await withThrowingTaskGroup(of: Void.self) { group in // Mark 1 |
||||
|
||||
for file in files { |
||||
group.addTask { [sourceFileDownloadTracker] in |
||||
let success = try await NetworkManager.shared.downloadRankingData(lastDateString: lastStringDate, fileName: file) |
||||
await sourceFileDownloadTracker.updateIfNecessary(with: success) |
||||
} |
||||
} |
||||
|
||||
try await group.waitForAll() |
||||
} |
||||
|
||||
// if current < Date() { |
||||
// if let nextCurrent = Calendar.current.date(byAdding: .month, value: 1, to: current) { |
||||
// await fetchData(fromDate: nextCurrent) |
||||
// } |
||||
// } |
||||
} catch { |
||||
print("downloadRankingData", error) |
||||
|
||||
if mostRecentDateAvailable == nil { |
||||
if let previousDate = Calendar.current.date(byAdding: .month, value: -1, to: current) { |
||||
await fetchData(fromDate: previousDate) |
||||
} |
||||
} |
||||
} |
||||
let downloadedFileStatus = await sourceFileDownloadTracker.getDownloadedFileStatus() |
||||
|
||||
return downloadedFileStatus |
||||
} |
||||
|
||||
func getAllFiles(initialDate: String = "08-2022") async { |
||||
let dates = monthsBetweenDates(startDateString: initialDate, endDateString: Date().monthYearFormatted) |
||||
.compactMap { |
||||
URL.importDateFormatter.date(from: $0) |
||||
} |
||||
.filter { date in |
||||
allFiles.contains(where: { $0.dateFromPath == date }) == false |
||||
} |
||||
|
||||
try? await dates.concurrentForEach { date in |
||||
await self.fetchData(fromDate: date) |
||||
} |
||||
} |
||||
|
||||
func monthsBetweenDates(startDateString: String, endDateString: String) -> [String] { |
||||
let dateFormatter = URL.importDateFormatter |
||||
|
||||
guard let startDate = dateFormatter.date(from: startDateString), |
||||
let endDate = dateFormatter.date(from: endDateString) else { |
||||
return [] |
||||
} |
||||
|
||||
var months: [String] = [] |
||||
var currentDate = startDate |
||||
let calendar = Calendar.current |
||||
|
||||
while currentDate <= endDate { |
||||
let monthString = dateFormatter.string(from: currentDate) |
||||
months.append(monthString) |
||||
|
||||
guard let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: currentDate) else { |
||||
break |
||||
} |
||||
currentDate = nextMonthDate |
||||
} |
||||
return months |
||||
} |
||||
|
||||
func getUnrankValue(forMale: Bool, rankSourceDate: Date?) -> Int? { |
||||
let _rankSourceDate = rankSourceDate ?? mostRecentDateAvailable |
||||
let urls = allFiles(forMale).filter { $0.dateFromPath == _rankSourceDate } |
||||
return urls.compactMap { $0.getUnrankedValue() }.sorted().last |
||||
} |
||||
|
||||
var mostRecentDateAvailable: Date? { |
||||
allFiles(false).first?.dateFromPath |
||||
} |
||||
|
||||
func removeAllFilesFromServer() { |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil) |
||||
allFiles.filter { $0.pathExtension == "csv" }.forEach { url in |
||||
try? FileManager.default.removeItem(at: url) |
||||
} |
||||
} |
||||
|
||||
func jsonFiles() -> [URL] { |
||||
let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in |
||||
url.pathExtension == "json" |
||||
}) |
||||
return allJSONFiles |
||||
} |
||||
|
||||
var allFiles: [URL] { |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in |
||||
url.pathExtension == "csv" |
||||
}) |
||||
|
||||
return (allFiles + (Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil) ?? [])).sorted { $0.dateFromPath == $1.dateFromPath ? $0.index < $1.index : $0.dateFromPath > $1.dateFromPath } |
||||
} |
||||
|
||||
func allFiles(_ isManPlayer: Bool) -> [URL] { |
||||
allFiles.filter({ url in |
||||
url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue) |
||||
}) |
||||
} |
||||
|
||||
func allFilesSortedByDate(_ isManPlayer: Bool) -> [URL] { |
||||
return allFiles(isManPlayer) |
||||
} |
||||
|
||||
static func isDateAfterUrlImportDate(date: Date, dateString: String) -> Bool { |
||||
guard let importDate = URL.importDateFormatter.date(from: dateString) else { |
||||
return false |
||||
} |
||||
|
||||
return importDate.isEarlierThan(date) |
||||
} |
||||
} |
||||
|
||||
enum SourceFile: String, CaseIterable { |
||||
case dames = "DAMES" |
||||
case messieurs = "MESSIEURS" |
||||
|
||||
var filesFromServer: [URL] { |
||||
let rankingSourceDirectory = SourceFileManager.shared.rankingSourceDirectory |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil) |
||||
return allFiles.filter{$0.pathExtension == "csv" && $0.path().contains(rawValue)} |
||||
} |
||||
|
||||
func currentURLs(importingDate: Date) -> [URL] { |
||||
var files = Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil)?.filter({ url in |
||||
url.path().contains(rawValue) |
||||
}) ?? [] |
||||
|
||||
files.append(contentsOf: filesFromServer) |
||||
return files.filter({ $0.dateFromPath == importingDate }) |
||||
} |
||||
|
||||
var isMan: Bool { |
||||
switch self { |
||||
case .dames: |
||||
return false |
||||
default: |
||||
return true |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
// |
||||
// URLs.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 22/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum URLs: String, Identifiable { |
||||
|
||||
#if DEBUG |
||||
case activationHost = "xlr.alwaysdata.net" |
||||
case main = "https://xlr.alwaysdata.net/" |
||||
case api = "https://xlr.alwaysdata.net/roads/" |
||||
#else |
||||
case activationHost = "padelclub.app" |
||||
case main = "https://padelclub.app/" |
||||
case api = "https://padelclub.app/roads/" |
||||
#endif |
||||
case subscriptions = "https://apple.co/2Th4vqI" |
||||
case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/" |
||||
//case padelClub = "https://padelclub.app" |
||||
case tenup = "https://tenup.fft.fr" |
||||
case padelCompetitionGeneralGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi2PB5LeNNTxlrS_1-REGLESGENERALESDELACOMPETITION-ANNEESPORTIVE2025.pdf" |
||||
case padelCompetitionSpecificGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi4ax5LeNNTxlsu_3-CAHIERDESCHARGESDESTOURNOIS-ANNEESPORTIVE2025.pdf" |
||||
case padelRules = "https://xlr.alwaysdata.net/static/rules/padel-rules-2024.pdf" |
||||
case restingDischarge = "https://club.fft.fr/tennisfirmidecazeville/60120370_d/data_1/pdf/fo/formlairededechargederesponsabilitetournoidepadel.pdf" |
||||
case appReview = "https://apps.apple.com/app/padel-club/id6484163558?mt=8&action=write-review" |
||||
case appDescription = "https://padelclub.app/download/" |
||||
case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn" |
||||
case appStore = "https://apps.apple.com/app/padel-club/id6484163558" |
||||
|
||||
var id: String { return self.rawValue } |
||||
|
||||
var url: URL { |
||||
return URL(string: self.rawValue)! |
||||
} |
||||
|
||||
static func sitePage(component: String) -> String { |
||||
return "\(URLs.main.rawValue)\(component)" |
||||
} |
||||
|
||||
} |
||||
|
||||
enum PageLink: String, Identifiable, CaseIterable { |
||||
case teams = "Équipes" |
||||
case summons = "Convocations" |
||||
case groupStages = "Poules" |
||||
case matches = "Tournoi" |
||||
case rankings = "Classement" |
||||
case broadcast = "Mode TV (Tournoi)" |
||||
case clubBroadcast = "Mode TV (Club)" |
||||
|
||||
var id: String { self.rawValue } |
||||
|
||||
func localizedLabel() -> String { |
||||
rawValue |
||||
} |
||||
|
||||
var path: String { |
||||
switch self { |
||||
case .matches: |
||||
return "" |
||||
case .teams: |
||||
return "teams" |
||||
case .summons: |
||||
return "summons" |
||||
case .rankings: |
||||
return "rankings" |
||||
case .groupStages: |
||||
return "group-stages" |
||||
case .broadcast: |
||||
return "broadcast" |
||||
case .clubBroadcast: |
||||
return "" |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,33 +0,0 @@ |
||||
// |
||||
// VersionComparator.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 13/02/2025. |
||||
// |
||||
|
||||
class VersionComparator { |
||||
|
||||
static func compare(_ version1: String, _ version2: String) -> Int { |
||||
// Split versions into components |
||||
let v1Components = version1.split(separator: ".").map { Int($0) ?? 0 } |
||||
let v2Components = version2.split(separator: ".").map { Int($0) ?? 0 } |
||||
|
||||
// Get the maximum length to compare |
||||
let maxLength = max(v1Components.count, v2Components.count) |
||||
|
||||
// Compare each component |
||||
for i in 0..<maxLength { |
||||
let v1Num = i < v1Components.count ? v1Components[i] : 0 |
||||
let v2Num = i < v2Components.count ? v2Components[i] : 0 |
||||
|
||||
if v1Num < v2Num { |
||||
return -1 // version1 is smaller |
||||
} else if v1Num > v2Num { |
||||
return 1 // version1 is larger |
||||
} |
||||
} |
||||
|
||||
return 0 // versions are equal |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,101 @@ |
||||
// |
||||
// MatchDescriptor.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 02/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class MatchDescriptor: ObservableObject { |
||||
@Published var matchFormat: MatchFormat |
||||
@Published var setDescriptors: [SetDescriptor] |
||||
var court: Int = 1 |
||||
var title: String = "Titre du match" |
||||
var teamLabelOne: String = "" |
||||
var teamLabelTwo: String = "" |
||||
var startDate: Date = Date() |
||||
var match: Match? |
||||
|
||||
init(match: Match? = nil) { |
||||
self.match = match |
||||
if let groupStage = match?.groupStageObject { |
||||
self.matchFormat = groupStage.matchFormat |
||||
self.setDescriptors = [SetDescriptor(setFormat: groupStage.matchFormat.setFormat)] |
||||
} else { |
||||
let format = match?.matchFormat ?? match?.currentTournament()?.matchFormat ?? .defaultFormatForMatchType(.groupStage) |
||||
self.matchFormat = format |
||||
self.setDescriptors = [SetDescriptor(setFormat: format.setFormat)] |
||||
} |
||||
let teamOne = match?.team(.one) |
||||
let teamTwo = match?.team(.two) |
||||
self.teamLabelOne = teamOne?.teamLabel(.wide, twoLines: true) ?? "" |
||||
self.teamLabelTwo = teamTwo?.teamLabel(.wide, twoLines: true) ?? "" |
||||
|
||||
if let match, let scoresTeamOne = match.teamScore(ofTeam: teamOne)?.score, let scoresTeamTwo = match.teamScore(ofTeam: teamTwo)?.score { |
||||
|
||||
self.setDescriptors = combineArraysIntoTuples(scoresTeamOne.components(separatedBy: ","), scoresTeamTwo.components(separatedBy: ",")).map({ (a:String?, b:String?) in |
||||
SetDescriptor(valueTeamOne: a != nil ? Int(a!) : nil, valueTeamTwo: b != nil ? Int(b!) : nil, setFormat: match.matchFormat.setFormat) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
var teamOneScores: [String] { |
||||
setDescriptors.compactMap { $0.valueTeamOne }.map { "\($0)" } |
||||
} |
||||
|
||||
var teamTwoScores: [String] { |
||||
setDescriptors.compactMap { $0.valueTeamTwo }.map { "\($0)" } |
||||
} |
||||
|
||||
var scoreTeamOne: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .one }.count } |
||||
var scoreTeamTwo: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .two }.count } |
||||
|
||||
var hasEnded: Bool { |
||||
return matchFormat.hasEnded(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) |
||||
} |
||||
|
||||
func addNewSet() { |
||||
if hasEnded == false { |
||||
setDescriptors.append(SetDescriptor(setFormat: matchFormat.newSetFormat(setCount: setDescriptors.count))) |
||||
} |
||||
} |
||||
|
||||
var winner: TeamPosition { |
||||
matchFormat.winner(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) |
||||
} |
||||
|
||||
var winnerLabel: String { |
||||
if winner == .one { |
||||
return teamLabelOne |
||||
} else { |
||||
return teamLabelTwo |
||||
} |
||||
} |
||||
} |
||||
|
||||
fileprivate func combineArraysIntoTuples(_ array1: [String], _ array2: [String]) -> [(String?, String?)] { |
||||
// Zip the two arrays together and map them to tuples of optional strings |
||||
let combined = zip(array1, array2).map { (element1, element2) in |
||||
return (element1, element2) |
||||
} |
||||
|
||||
// If one array is longer than the other, append the remaining elements |
||||
let remainingElements: [(String?, String?)] |
||||
if array1.count > array2.count { |
||||
let remaining = Array(array1[array2.count...]).map { (element) in |
||||
return (element, nil as String?) |
||||
} |
||||
remainingElements = remaining |
||||
} else if array2.count > array1.count { |
||||
let remaining = Array(array2[array1.count...]).map { (element) in |
||||
return (nil as String?, element) |
||||
} |
||||
remainingElements = remaining |
||||
} else { |
||||
remainingElements = [] |
||||
} |
||||
|
||||
// Concatenate the two arrays |
||||
return combined + remainingElements |
||||
} |
||||
@ -1,51 +0,0 @@ |
||||
// |
||||
// MatchViewStyle.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 17/11/2024. |
||||
// |
||||
|
||||
import SwiftUI |
||||
|
||||
enum MatchViewStyle { |
||||
case standardStyle // vue normal |
||||
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche |
||||
case feedStyle // vue programmation |
||||
case plainStyle // vue detail |
||||
//case tournamentResultStyle //vue resultat tournoi |
||||
case followUpStyle // vue normal |
||||
|
||||
func displayRestingTime() -> Bool { |
||||
switch self { |
||||
case .standardStyle: |
||||
return false |
||||
case .sectionedStandardStyle: |
||||
return false |
||||
case .feedStyle: |
||||
return false |
||||
case .plainStyle: |
||||
return false |
||||
// case .tournamentResultStyle: |
||||
// return false |
||||
case .followUpStyle: |
||||
return true |
||||
} |
||||
} |
||||
} |
||||
|
||||
struct MatchViewStyleKey: EnvironmentKey { |
||||
static let defaultValue: MatchViewStyle = .standardStyle |
||||
} |
||||
|
||||
extension EnvironmentValues { |
||||
var matchViewStyle: MatchViewStyle { |
||||
get { self[MatchViewStyleKey.self] } |
||||
set { self[MatchViewStyleKey.self] = newValue } |
||||
} |
||||
} |
||||
|
||||
extension View { |
||||
func matchViewStyle(_ style: MatchViewStyle) -> some View { |
||||
environment(\.matchViewStyle, style) |
||||
} |
||||
} |
||||
@ -0,0 +1,51 @@ |
||||
// |
||||
// Selectable.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 01/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
|
||||
protocol Selectable { |
||||
func selectionLabel(index: Int) -> String |
||||
func badgeValue() -> Int? |
||||
func badgeImage() -> Badge? |
||||
func badgeValueColor() -> Color? |
||||
func displayImageIfValueZero() -> Bool |
||||
} |
||||
|
||||
extension Selectable { |
||||
func displayImageIfValueZero() -> Bool { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
enum Badge { |
||||
case checkmark |
||||
case xmark |
||||
case custom(systemName: String, color: Color) |
||||
|
||||
func systemName() -> String { |
||||
switch self { |
||||
case .checkmark: |
||||
return "checkmark.circle.fill" |
||||
case .xmark: |
||||
return "xmark.circle.fill" |
||||
case .custom(let systemName, _): |
||||
return systemName |
||||
} |
||||
} |
||||
|
||||
func color() -> Color { |
||||
switch self { |
||||
case .checkmark: |
||||
.green |
||||
case .xmark: |
||||
.logoRed |
||||
case .custom(_, let color): |
||||
color |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
// |
||||
// SetDescriptor.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 02/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
struct SetDescriptor: Identifiable, Equatable { |
||||
let id: UUID = UUID() |
||||
var valueTeamOne: Int? |
||||
var valueTeamTwo: Int? |
||||
var tieBreakValueTeamOne: Int? |
||||
var tieBreakValueTeamTwo: Int? |
||||
var setFormat: SetFormat |
||||
|
||||
var hasEnded: Bool { |
||||
if let valueTeamTwo, let valueTeamOne { |
||||
return setFormat.hasEnded(teamOne: valueTeamOne, teamTwo: valueTeamTwo) |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
var winner: TeamPosition? { |
||||
if let valueTeamTwo, let valueTeamOne { |
||||
return setFormat.winner(teamOne: valueTeamOne, teamTwo: valueTeamTwo) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
@ -1,215 +0,0 @@ |
||||
// |
||||
// BracketCallingView.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 15/10/2024. |
||||
// |
||||
|
||||
import SwiftUI |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
struct BracketCallingView: View { |
||||
@EnvironmentObject var dataStore: DataStore |
||||
@Environment(Tournament.self) var tournament: Tournament |
||||
@State private var initialSeedRound: Int = 0 |
||||
@State private var initialSeedCount: Int = 0 |
||||
let tournamentRounds: [Round] |
||||
let teams: [TeamRegistration] |
||||
|
||||
init(tournament: Tournament) { |
||||
let rounds = tournament.rounds() |
||||
self.tournamentRounds = rounds |
||||
self.teams = tournament.availableSeeds() |
||||
if tournament.initialSeedRound == 0, rounds.count > 0 { |
||||
let index = rounds.count - 1 |
||||
_initialSeedRound = .init(wrappedValue: index) |
||||
_initialSeedCount = .init(wrappedValue: RoundRule.numberOfMatches(forRoundIndex: index)) |
||||
} else if tournament.initialSeedRound < rounds.count { |
||||
_initialSeedRound = .init(wrappedValue: tournament.initialSeedRound) |
||||
_initialSeedCount = .init(wrappedValue: tournament.initialSeedCount) |
||||
} else if rounds.count > 0 { |
||||
let index = rounds.count - 1 |
||||
_initialSeedRound = .init(wrappedValue: index) |
||||
_initialSeedCount = .init(wrappedValue: RoundRule.numberOfMatches(forRoundIndex: index)) |
||||
} |
||||
} |
||||
|
||||
var initialRound: Round { |
||||
tournamentRounds.first(where: { $0.index == initialSeedRound })! |
||||
} |
||||
|
||||
func filteredRounds() -> [Round] { |
||||
tournamentRounds.filter({ $0.index >= initialSeedRound }).reversed() |
||||
} |
||||
|
||||
func seedCount(forRoundIndex roundIndex: Int) -> Int { |
||||
if roundIndex < initialSeedRound { return 0 } |
||||
if roundIndex == initialSeedRound { |
||||
return initialSeedCount |
||||
} |
||||
|
||||
let seedCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex) |
||||
let previousSeedCount = self.seedCount(forRoundIndex: roundIndex - 1) |
||||
|
||||
let total = seedCount - previousSeedCount |
||||
if total < 0 { return 0 } |
||||
return total |
||||
} |
||||
|
||||
func seeds(forRoundIndex roundIndex: Int) -> [TeamRegistration] { |
||||
let previousSeeds: Int = (initialSeedRound..<roundIndex).map { seedCount(forRoundIndex: $0) }.reduce(0, +) |
||||
|
||||
|
||||
if roundIndex == tournamentRounds.count - 1 { |
||||
return Array(teams.dropFirst(previousSeeds)) |
||||
} else { |
||||
return Array(teams.dropFirst(previousSeeds).prefix(seedCount(forRoundIndex: roundIndex))) |
||||
} |
||||
} |
||||
|
||||
|
||||
var body: some View { |
||||
List { |
||||
let uncalledTeams = teams.filter({ $0.callDate == nil }) |
||||
if uncalledTeams.isEmpty == false { |
||||
NavigationLink { |
||||
TeamsCallingView(teams: uncalledTeams) |
||||
.environment(tournament) |
||||
} label: { |
||||
LabeledContent("Équipe\(uncalledTeams.count.pluralSuffix) non contactée\(uncalledTeams.count.pluralSuffix)", value: uncalledTeams.count.formatted()) |
||||
} |
||||
} |
||||
|
||||
PlayersWithoutContactView(players: teams.flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank)) |
||||
|
||||
Section { |
||||
Picker(selection: $initialSeedRound) { |
||||
ForEach(tournamentRounds) { |
||||
Text($0.roundTitle()).tag($0.index) |
||||
} |
||||
} label: { |
||||
Text("Premier tour") |
||||
} |
||||
.onChange(of: initialSeedRound) { |
||||
initialSeedCount = RoundRule.numberOfMatches(forRoundIndex: initialSeedRound) |
||||
} |
||||
|
||||
LabeledContent { |
||||
StepperView(count: $initialSeedCount, minimum: 0, maximum: RoundRule.numberOfMatches(forRoundIndex: initialSeedRound)) |
||||
} label: { |
||||
Text("Têtes de série") |
||||
} |
||||
} footer: { |
||||
Text("Permet de convoquer par tour du tableau sans avoir tirer au sort les tétes de série. Vous pourrez ensuite confirmer leur horaire plus précis si le tour se joue sur plusieurs rotations. Les équipes ne peuvent pas être considéré comme convoqué au bon horaire en dehors de cet écran tant qu'elles n'ont pas été placé dans le tableau.") |
||||
} |
||||
|
||||
ForEach(filteredRounds()) { round in |
||||
let seeds = seeds(forRoundIndex: round.index) |
||||
let startDate = ([round.startDate] + round.playedMatches().map { $0.startDate }).compacted().min() |
||||
let callSeeds = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0, expectedSummonDate: startDate) == false }) |
||||
if seeds.isEmpty == false { |
||||
Section { |
||||
NavigationLink { |
||||
_roundView(round: round, seeds: seeds) |
||||
.environment(tournament) |
||||
} label: { |
||||
CallView.CallStatusView(count: callSeeds.count, total: seeds.count, startDate: startDate, title: "convoquées") |
||||
} |
||||
} header: { |
||||
Text(round.roundTitle()) |
||||
} footer: { |
||||
if let startDate { |
||||
CallView(teams: seeds, callDate: startDate, matchFormat: round.matchFormat, roundLabel: round.roundTitle()) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
.onDisappear(perform: { |
||||
tournament.initialSeedCount = initialSeedCount |
||||
tournament.initialSeedRound = initialSeedRound |
||||
_save() |
||||
}) |
||||
.headerProminence(.increased) |
||||
.navigationTitle("Pré-convocation") |
||||
} |
||||
|
||||
private func _save() { |
||||
do { |
||||
try dataStore.tournaments.addOrUpdate(instance: tournament) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder |
||||
private func _roundView(round: Round, seeds: [TeamRegistration]) -> some View { |
||||
List { |
||||
|
||||
let uncalledTeams = seeds.filter({ $0.callDate == nil }) |
||||
if uncalledTeams.isEmpty == false { |
||||
NavigationLink { |
||||
TeamsCallingView(teams: uncalledTeams) |
||||
.environment(tournament) |
||||
} label: { |
||||
LabeledContent("Équipe\(uncalledTeams.count.pluralSuffix) non contactée\(uncalledTeams.count.pluralSuffix)", value: uncalledTeams.count.formatted()) |
||||
} |
||||
} |
||||
|
||||
let startDate = round.startDate ?? round.playedMatches().first?.startDate |
||||
let badCalled = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0, expectedSummonDate: startDate) }) |
||||
|
||||
if badCalled.isEmpty == false { |
||||
Section { |
||||
ForEach(badCalled) { team in |
||||
TeamCallView(team: team) |
||||
} |
||||
} header: { |
||||
HStack { |
||||
Text("Mauvais horaire") |
||||
Spacer() |
||||
Text(badCalled.count.formatted() + " équipe\(badCalled.count.pluralSuffix)") |
||||
} |
||||
} footer: { |
||||
if let startDate { |
||||
CallView(teams: badCalled, callDate: startDate, matchFormat: round.matchFormat, roundLabel: round.roundTitle()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
Section { |
||||
ForEach(seeds) { team in |
||||
TeamCallView(team: team) |
||||
} |
||||
} header: { |
||||
HStack { |
||||
Text(round.roundTitle()) |
||||
Spacer() |
||||
Text(seeds.count.formatted() + " équipe\(seeds.count.pluralSuffix)") |
||||
} |
||||
} |
||||
} |
||||
.overlay { |
||||
if seeds.isEmpty { |
||||
ContentUnavailableView { |
||||
Label("Aucune équipe dans ce tour", systemImage: "clock.badge.questionmark") |
||||
} description: { |
||||
Text("Padel Club n'a pas réussi à déterminer quelles équipes jouent ce tour.") |
||||
} actions: { |
||||
// RowButtonView("Horaire intelligent") { |
||||
// selectedScheduleDestination = nil |
||||
// } |
||||
} |
||||
} |
||||
} |
||||
.headerProminence(.increased) |
||||
.navigationTitle(round.roundTitle()) |
||||
.navigationBarTitleDisplayMode(.inline) |
||||
.toolbarBackground(.visible, for: .navigationBar) |
||||
} |
||||
} |
||||
|
||||
//#Preview { |
||||
// SeedsCallingView() |
||||
//} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue