Compare commits

..

2 Commits
main ... compil

  1. 8
      CLAUDE.md
  2. 2013
      PadelClub.xcodeproj/project.pbxproj
  3. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  4. 78
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  5. 4
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  6. 2
      PadelClub.xcworkspace/contents.xcworkspacedata
  7. 61
      PadelClub/AppDelegate.swift
  8. 33
      PadelClub/Assets.xcassets/beigeNotUniversal.colorset/Contents.json
  9. 33
      PadelClub/Assets.xcassets/grayNotUniversal.colorset/Contents.json
  10. 6
      PadelClub/Assets.xcassets/logoRed.colorset/Contents.json
  11. 6
      PadelClub/Assets.xcassets/logoYellow.colorset/Contents.json
  12. 86
      PadelClub/Data/Club+Extensions.swift
  13. 40
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  14. 30
      PadelClub/Data/Coredata/PadelClubApp.xcdatamodeld/Model_1_1.xcdatamodel/contents
  15. 47
      PadelClub/Data/Coredata/Persistence.swift
  16. 73
      PadelClub/Data/Enum+Extensions.swift
  17. 77
      PadelClub/Data/Federal/FederalPlayer.swift
  18. 289
      PadelClub/Data/Federal/FederalTournament.swift
  19. 8
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  20. 16
      PadelClub/Data/Federal/PlayerHolder.swift
  21. 55
      PadelClub/Data/GroupStage+Extensions.swift
  22. 89
      PadelClub/Data/Match+Extensions.swift
  23. 13
      PadelClub/Data/MatchScheduler+Extensions.swift
  24. 28
      PadelClub/Data/MonthData+Extensions.swift
  25. 230
      PadelClub/Data/PlayerRegistration+Extensions.swift
  26. 21
      PadelClub/Data/README.md
  27. 155
      PadelClub/Data/Round+Extensions.swift
  28. 20
      PadelClub/Data/SeedInterval+Extensions.swift
  29. 109
      PadelClub/Data/TeamRegistration+Extensions.swift
  30. 395
      PadelClub/Data/Tournament+Extensions.swift
  31. 2704
      PadelClub/Data/Tournament.swift
  32. 13
      PadelClub/Data/User+Extensions.swift
  33. 38
      PadelClub/Extensions/Array+Extensions.swift
  34. 25
      PadelClub/Extensions/Badge+Extensions.swift
  35. 26
      PadelClub/Extensions/Calendar+Extensions.swift
  36. 180
      PadelClub/Extensions/Date+Extensions.swift
  37. 25
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  38. 23
      PadelClub/Extensions/Locale+Extensions.swift
  39. 16
      PadelClub/Extensions/NumberFormatter+Extensions.swift
  40. 230
      PadelClub/Extensions/PlayerRegistration+Extensions.swift
  41. 33
      PadelClub/Extensions/Round+Extensions.swift
  42. 33
      PadelClub/Extensions/Sequence+Extensions.swift
  43. 34
      PadelClub/Extensions/SourceFileManager+Extensions.swift
  44. 42
      PadelClub/Extensions/SpinDrawable+Extensions.swift
  45. 151
      PadelClub/Extensions/String+Extensions.swift
  46. 84
      PadelClub/Extensions/TeamRegistration+Extensions.swift
  47. 428
      PadelClub/Extensions/Tournament+Extensions.swift
  48. 22
      PadelClub/Extensions/View+Extensions.swift
  49. 5
      PadelClub/HTML Templates/bracket-template.html
  50. 1
      PadelClub/HTML Templates/groupstage-template.html
  51. 14
      PadelClub/HTML Templates/match-template.html
  52. 1
      PadelClub/HTML Templates/player-template.html
  53. 36
      PadelClub/HTML Templates/tournament-template.html
  54. 2
      PadelClub/Info.plist
  55. 60
      PadelClub/OnlineRegistrationWarningView.swift
  56. 190
      PadelClub/PadelClubApp.swift
  57. BIN
      PadelClub/SeedData/local.sqlite
  58. 69
      PadelClub/SyncedProducts.storekit
  59. 224
      PadelClub/Utils/CloudConvert.swift
  60. 205
      PadelClub/Utils/ContactManager.swift
  61. 23
      PadelClub/Utils/DisplayContext.swift
  62. 37
      PadelClub/Utils/ExportFormat.swift
  63. 147
      PadelClub/Utils/FileImportManager.swift
  64. 25
      PadelClub/Utils/HtmlGenerator.swift
  65. 160
      PadelClub/Utils/HtmlService.swift
  66. 17
      PadelClub/Utils/LocationManager.swift
  67. 90
      PadelClub/Utils/Network/ConfigurationService.swift
  68. 302
      PadelClub/Utils/Network/FederalDataService.swift
  69. 142
      PadelClub/Utils/Network/NetworkFederalService.swift
  70. 5
      PadelClub/Utils/Network/NetworkManager.swift
  71. 28
      PadelClub/Utils/Network/NetworkManagerError.swift
  72. 77
      PadelClub/Utils/Network/PaymentService.swift
  73. 50
      PadelClub/Utils/Network/RefundService.swift
  74. 207
      PadelClub/Utils/Network/StripeValidationService.swift
  75. 83
      PadelClub/Utils/Network/XlsToCsvService.swift
  76. 59
      PadelClub/Utils/PhoneNumbersUtils.swift
  77. 257
      PadelClub/Utils/SourceFileManager.swift
  78. 72
      PadelClub/Utils/SwiftParser.swift
  79. 200
      PadelClub/Utils/Tips.swift
  80. 80
      PadelClub/Utils/URLs.swift
  81. 33
      PadelClub/Utils/VersionComparator.swift
  82. 59
      PadelClub/ViewModel/AgendaDestination.swift
  83. 146
      PadelClub/ViewModel/FederalDataViewModel.swift
  84. 101
      PadelClub/ViewModel/MatchDescriptor.swift
  85. 51
      PadelClub/ViewModel/MatchViewStyle.swift
  86. 3
      PadelClub/ViewModel/NavigationViewModel.swift
  87. 732
      PadelClub/ViewModel/SearchViewModel.swift
  88. 51
      PadelClub/ViewModel/Selectable.swift
  89. 33
      PadelClub/ViewModel/SetDescriptor.swift
  90. 5
      PadelClub/ViewModel/TabDestination.swift
  91. 215
      PadelClub/Views/Calling/BracketCallingView.swift
  92. 52
      PadelClub/Views/Calling/CallMessageCustomizationView.swift
  93. 13
      PadelClub/Views/Calling/CallSettingsView.swift
  94. 341
      PadelClub/Views/Calling/CallView.swift
  95. 55
      PadelClub/Views/Calling/Components/MenuWarningView.swift
  96. 8
      PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift
  97. 39
      PadelClub/Views/Calling/GroupStageCallingView.swift
  98. 44
      PadelClub/Views/Calling/SeedsCallingView.swift
  99. 196
      PadelClub/Views/Calling/SendToAllView.swift
  100. 206
      PadelClub/Views/Calling/TeamsCallingView.swift
  101. Some files were not shown because too many files have changed in this diff Show More

@ -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,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -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,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -74,7 +74,7 @@
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../PadelClub/SyncedProducts.storekit">
identifier = "../../PadelClub/SyncedProducts.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction

@ -5,7 +5,7 @@
location = "group:../LeStorage/LeStorage.xcodeproj">
</FileRef>
<FileRef
location = "container:../PadelClubData/PadelClubData.xcodeproj">
location = "group:PadelClubData/PadelClubData.xcodeproj">
</FileRef>
<FileRef
location = "group:PadelClub.xcodeproj">

@ -9,76 +9,21 @@ import Foundation
import UIKit
import LeStorage
import UserNotifications
import PadelClubData
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
_ = Guard.main // init guard
self._configureLeStorage()
UIApplication.shared.registerForRemoteNotifications()
UNUserNotificationCenter.current().delegate = self
return true
}
fileprivate func _domain() -> String {
#if DEBUG
return "xlr.alwaysdata.net"
#elseif TESTFLIGHT
return "padelclub.app"
#elseif PRODTEST
return "padelclub.app"
#else
return "padelclub.app"
#endif
}
fileprivate func _configureLeStorage() {
StoreCenter.main.blackListUserName("apple-test")
StoreCenter.main.classProject = "PadelClubData"
// let secureScheme = true
let domain: String = self._domain()
#if DEBUG
if let secure = PListReader.readBool(plist: "local", key: "secure_server"),
let domain = PListReader.readString(plist: "local", key: "server_domain") {
StoreCenter.main.configureURLs(secureScheme: secure, domain: domain, webSockets: true, useSynchronization: true)
} else {
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
}
#else
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
#endif
StoreCenter.main.logsFailedAPICalls()
var synchronized: Bool = true
#if DEBUG
if let sync = PListReader.readBool(plist: "local", key: "synchronized") {
synchronized = sync
}
#endif
StoreCenter.main.forceNoSynchronization = !synchronized
}
func applicationWillEnterForeground(_ application: UIApplication) {
Task {
await Guard.main.refreshPurchases()
}
Logger.log("didFinishLaunchingWithOptions")
return true
}
// MARK: - Remote Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if StoreCenter.main.isAuthenticated {
if StoreCenter.main.hasToken() {
Task {
do {
let services = try StoreCenter.main.service()

@ -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
}
}

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x38",
"green" : "0x40",
"red" : "0xE8"
"blue" : "0.220",
"green" : "0.251",
"red" : "0.910"
}
},
"idiom" : "universal"

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0xD2",
"red" : "0xFF"
"blue" : "0.000",
"green" : "0.827",
"red" : "1.000"
}
},
"idiom" : "universal"

@ -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!)
}
}

@ -6,7 +6,6 @@
//
import Foundation
import PadelClubData
extension ImportedPlayer: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
@ -14,13 +13,7 @@ extension ImportedPlayer: PlayerHolder {
return getRank()?.femaleInMaleAssimilation
}
var computedAge: Int? {
let year = Calendar.current.getSportAge()
if let yearOfBirth = birthYear?.toInt() {
return year - yearOfBirth
}
return nil
}
var computedAge: Int? { nil }
var tournamentPlayed: Int? {
Int(tournamentCount)
@ -58,11 +51,7 @@ extension ImportedPlayer: PlayerHolder {
func isMalePlayer() -> Bool {
male
}
func pasteData(withRank: Bool = false) -> String {
return [firstName?.capitalized, lastName?.capitalized, license?.computedLicense, withRank ? "(\(rank.ordinalFormatted(feminine: isMalePlayer() == false)))" : nil].compactMap({ $0 }).joined(separator: " ")
}
func isNotFromCurrentDate() -> Bool {
if let importDate, importDate != SourceFileManager.shared.lastDataSourceDate() {
return true
@ -71,12 +60,7 @@ extension ImportedPlayer: PlayerHolder {
}
}
func contains(_ searchField: String) -> Bool {
firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true
}
func hitForSearch(_ searchText: String?) -> Int {
guard let searchText else { return 0 }
func hitForSearch(_ searchText: String) -> Int {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ")
@ -112,26 +96,10 @@ extension ImportedPlayer: PlayerHolder {
}
return 0
}
func getBirthYear() -> Int? {
if let birthYear {
return Int(birthYear)
} else {
return nil
}
}
func getProgression() -> Int {
return Int(progression)
}
func getComputedRank() -> Int? {
nil
}
}
fileprivate extension Int {
var femaleInMaleAssimilation: Int {
self + TournamentCategory.femaleInMaleAssimilationAddition(self, seasonYear: Date.now.seasonYear())
self + TournamentCategory.femaleInMaleAssimilationAddition(self)
}
}

@ -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>

@ -10,27 +10,7 @@ import CoreData
class PersistenceController: NSObject {
static let shared = PersistenceController()
private static var prepopulatedSeed: URL? {
let url = Bundle.main.url(forResource: "local", withExtension: "sqlite")
print("prepopulatedSeed", url)
return url
}
private static var _model: NSManagedObjectModel?
static func getModelVersion() -> String? {
if let versions = _model?.versionIdentifiers {
let currentVersion = versions.compactMap { $0 as? String }.joined(separator: ",")
//print("Current Model Version: \(currentVersion)")
// Compare the current model version with the saved version
return currentVersion
}
return nil
}
private static func model(name: String) throws -> NSManagedObjectModel {
if _model == nil {
_model = try loadModel(name: name, bundle: Bundle.main)
@ -59,19 +39,6 @@ class PersistenceController: NSObject {
let localStoreFolderURL = storeFolderURL.appendingPathComponent("Local")
let fileManager = FileManager.default
var firstLaunch = false
if fileManager.fileExists(atPath: localStoreFolderURL.appendingPathComponent("local.sqlite").path) == false {
firstLaunch = true
}
do {
let contents = try fileManager.contentsOfDirectory(atPath: localStoreFolderURL.path)
print("Directory contents: \(contents)")
} catch {
print("Failed to list directory contents: \(error)")
}
for folderURL in [localStoreFolderURL] where !fileManager.fileExists(atPath: folderURL.path) {
do {
try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
@ -80,17 +47,6 @@ class PersistenceController: NSObject {
}
}
if firstLaunch, let seedURL = PersistenceController.prepopulatedSeed {
let folderPath = seedURL.path()
let localStoreFileURL = localStoreFolderURL.appendingPathComponent("local.sqlite")
do {
try fileManager.copyItem(at: seedURL, to: localStoreFileURL)
} catch {
print("Error info: \(error)")
}
}
let container = NSPersistentContainer(name: "PadelClubApp", managedObjectModel: try! Self.model(name: "PadelClubApp"))
guard let localStoreDescription = container.persistentStoreDescriptions.first!.copy() as? NSPersistentStoreDescription else {
@ -219,9 +175,6 @@ class PersistenceController: NSObject {
importedPlayer.clubCode = data.clubCode.replaceCharactersFromSet(characterSet: .whitespaces)
importedPlayer.male = data.isMale
importedPlayer.importDate = importingDate
importedPlayer.progression = Int64(data.progression)
importedPlayer.bestRank = data.bestRank?.formattedAsRawString()
importedPlayer.birthYear = data.birthYear?.formattedAsRawString()
}
// 5

@ -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
}
}

@ -20,9 +20,6 @@ class FederalPlayer: Decodable {
var clubCode: String
var club: String
var isMale: Bool
var birthYear: Int?
var progression: Int
var bestRank: Int?
// MARK: - Nationnalite
@ -31,75 +28,54 @@ class FederalPlayer: Decodable {
}
required init(from decoder: Decoder) throws {
/*
"classement": 9,
"evolution": 2,
"nom": "PEREZ LE TIEC",
"prenom": "Pierre",
"meilleurClassement": null,
"nationalite": "FRA",
"ageSportif": 30,
"points": 14210,
"nombreTournoisJoues": 24,
"ligue": "ILE DE FRANCE",
"assimilation": false
*/
enum CodingKeys: String, CodingKey {
case nom
case prenom
case licence
case meilleurClassement
case nationalite
case nationnalite
case anneeNaissance
case codeClub
case nomClub
case ligue
case classement
case evolution
case nomLigue
case rang
case progression
case points
case nombreTournoisJoues
case assimilation
case ageSportif
case nombreDeTournois
case assimile
}
let container = try decoder.container(keyedBy: CodingKeys.self)
isMale = (decoder.userInfo[.maleData] as? Bool) == true
let _lastName = try container.decodeIfPresent(String.self, forKey: .nom)
let _firstName = try container.decodeIfPresent(String.self, forKey: .prenom)
lastName = _lastName ?? ""
firstName = _firstName ?? ""
let _lastName = try container.decode(String.self, forKey: .nom)
let _firstName = try container.decode(String.self, forKey: .prenom)
lastName = _lastName
firstName = _firstName
if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) {
license = String(lic)
} else {
license = ""
}
country = try container.decodeIfPresent(String.self, forKey: .nationalite) ?? ""
bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement)
let ageSportif = try container.decodeIfPresent(Int.self, forKey: .ageSportif)
if let ageSportif {
let month = Calendar.current.component(.month, from: Date())
if month > 8 {
birthYear = Calendar.current.component(.year, from: Date()) + 1 - ageSportif
} else {
birthYear = Calendar.current.component(.year, from: Date()) - ageSportif
}
}
clubCode = try container.decodeIfPresent(String.self, forKey: .codeClub) ?? ""
club = try container.decodeIfPresent(String.self, forKey: .nomClub) ?? ""
ligue = try container.decodeIfPresent(String.self, forKey: .ligue) ?? ""
rank = try container.decode(Int.self, forKey: .classement)
progression = (try? container.decodeIfPresent(Int.self, forKey: .evolution)) ?? 0
let nationnalite = try container.decode(Nationnalite.self, forKey: .nationnalite)
country = nationnalite.code
//meilleurClassement = try container.decode(Int.self, forKey: .meilleurClassement)
//anneeNaissance = try container.decode(Int.self, forKey: .anneeNaissance)
clubCode = try container.decode(String.self, forKey: .codeClub)
club = try container.decode(String.self, forKey: .nomClub)
ligue = try container.decode(String.self, forKey: .nomLigue)
rank = try container.decode(Int.self, forKey: .rang)
//progression = try? container.decodeIfPresent(Int.self, forKey: .progression)
let pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points)
if let pointsAsInt {
points = Double(pointsAsInt)
} else {
points = nil
}
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreTournoisJoues)
let assimile = try container.decode(Bool.self, forKey: .assimilation)
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreDeTournois)
let assimile = try container.decode(Bool.self, forKey: .assimile)
assimilation = assimile ? "Oui" : "Non"
}
@ -108,12 +84,11 @@ class FederalPlayer: Decodable {
let pointsString = points != nil ? String(Int(points!)) : ""
let tournamentCountString = tournamentCount != nil ? String(tournamentCount!) : ""
let strippedLicense = license.strippedLicense ?? ""
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);\(progression.formattedAsRawString());\(bestRank?.formattedAsRawString() ?? "");\(birthYear?.formattedAsRawString() ?? "");"
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);"
return line
}
func formatNumbers(_ input: String) -> String {
if input.isEmpty { return input }
// Insert spaces at appropriate positions
let formattedString = insertSeparator(input, separator: " ", every: [2, 4])
return formattedString
@ -192,9 +167,6 @@ class FederalPlayer: Decodable {
ligue = result[8]
clubCode = result[9]
club = result[10]
progression = result[safe: 11]?.toInt() ?? 0
bestRank = result[safe: 12]?.toInt()
birthYear = result[safe: 13]?.toInt()
}
static func anonymousCount(mostRecentDateAvailable: Date?) async -> Int? {
@ -227,9 +199,8 @@ class FederalPlayer: Decodable {
rankPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [rankPredicate, NSPredicate(format: "importDate == %@", mostRecentDateAvailable as CVarArg)])
}
fetch.predicate = rankPredicate
print(fetch.predicate)
let lastPlayersCount = try context.count(for: fetch)
print(Int(lr), Int(lastPlayersCount) - 1, count)
return (Int(lr) + Int(lastPlayersCount) - 1, count)
}
} catch {

@ -6,11 +6,15 @@
import Foundation
import CoreLocation
import LeStorage
import PadelClubData
enum DayPeriod {
case all
case weekend
case week
}
// MARK: - FederalTournament
struct FederalTournament: Identifiable, Codable, Hashable {
struct FederalTournament: Identifiable, Codable {
func getEvent() -> Event {
let club = DataStore.shared.user.clubsObjects().first(where: { $0.code == codeClub })
@ -23,14 +27,6 @@ struct FederalTournament: Identifiable, Codable, Hashable {
Logger.error(error)
}
}
if let club, club.creator == nil {
club.creator = StoreCenter.main.userId
do {
try DataStore.shared.clubs.addOrUpdate(instance: club)
} catch {
Logger.error(error)
}
}
return event!
}
@ -41,7 +37,7 @@ struct FederalTournament: Identifiable, Codable, Hashable {
}
let id: String
let id: Int
var millesime: Int?
var libelle: String?
var tmc: Bool?
@ -81,106 +77,8 @@ struct FederalTournament: Identifiable, Codable, Hashable {
var dateFin, dateValidation: Date?
var codePostalEngagement, codeClub: String?
var prixEspece: Int?
var japPhoneNumber: String?
mutating func updateJapPhoneNumber(phone: String?) {
self.japPhoneNumber = phone
}
var distanceEnMetres: Double?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle id that could be string or int
if let idString = try? container.decode(String.self, forKey: .id) {
id = idString
} else if let idInt = try? container.decode(Int.self, forKey: .id) {
id = String(idInt)
} else {
throw DecodingError.dataCorruptedError(forKey: .id, in: container,
debugDescription: "Expected String or Int for id")
}
// Regular decoding for simple properties
millesime = try container.decodeIfPresent(Int.self, forKey: .millesime)
libelle = try container.decodeIfPresent(String.self, forKey: .libelle)
tmc = try container.decodeIfPresent(Bool.self, forKey: .tmc)
tarifAdulteChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifAdulteChampionnat)
type = try container.decodeIfPresent(String.self, forKey: .type)
ageReel = try container.decodeIfPresent(Bool.self, forKey: .ageReel)
naturesTerrains = try container.decodeIfPresent([JSONAny].self, forKey: .naturesTerrains)
idsArbitres = try container.decodeIfPresent([JSONAny].self, forKey: .idsArbitres)
tarifJeuneChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifJeuneChampionnat)
international = try container.decodeIfPresent(Bool.self, forKey: .international)
inscriptionEnLigne = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigne)
categorieTournoi = try container.decodeIfPresent(CategorieTournoi.self, forKey: .categorieTournoi)
prixLot = try container.decodeIfPresent(Int.self, forKey: .prixLot)
paiementEnLigne = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigne)
reductionAdherentJeune = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentJeune)
reductionAdherentAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentAdulte)
paiementEnLigneObligatoire = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigneObligatoire)
villeEngagement = try container.decodeIfPresent(String.self, forKey: .villeEngagement)
senior = try container.decodeIfPresent(Bool.self, forKey: .senior)
veteran = try container.decodeIfPresent(Bool.self, forKey: .veteran)
inscriptionEnLigneEnCours = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigneEnCours)
avecResultatPublie = try container.decodeIfPresent(Bool.self, forKey: .avecResultatPublie)
code = try container.decodeIfPresent(String.self, forKey: .code)
categorieAge = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAge)
codeComite = try container.decodeIfPresent(String.self, forKey: .codeComite)
installations = try container.decodeIfPresent([JSONAny].self, forKey: .installations)
reductionEpreuveSupplementaireJeune = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireJeune)
reductionEpreuveSupplementaireAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireAdulte)
nomComite = try container.decodeIfPresent(String.self, forKey: .nomComite)
naturesEpreuves = try container.decodeIfPresent([Serie].self, forKey: .naturesEpreuves)
jeune = try container.decodeIfPresent(Bool.self, forKey: .jeune)
courrielEngagement = try container.decodeIfPresent(String.self, forKey: .courrielEngagement)
nomClub = try container.decodeIfPresent(String.self, forKey: .nomClub)
installation = try container.decodeIfPresent(Installation.self, forKey: .installation)
categorieAgeMax = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAgeMax)
tournoiInterne = try container.decodeIfPresent(Bool.self, forKey: .tournoiInterne)
nomLigue = try container.decodeIfPresent(String.self, forKey: .nomLigue)
nomEngagement = try container.decodeIfPresent(String.self, forKey: .nomEngagement)
codeLigue = try container.decodeIfPresent(String.self, forKey: .codeLigue)
modeleDeBalle = try container.decodeIfPresent(ModeleDeBalle.self, forKey: .modeleDeBalle)
jugeArbitre = try container.decodeIfPresent(JugeArbitre.self, forKey: .jugeArbitre)
adresse2Engagement = try container.decodeIfPresent(String.self, forKey: .adresse2Engagement)
epreuves = try container.decodeIfPresent([Epreuve].self, forKey: .epreuves)
serie = try container.decodeIfPresent(Serie.self, forKey: .serie)
codePostalEngagement = try container.decodeIfPresent(String.self, forKey: .codePostalEngagement)
codeClub = try container.decodeIfPresent(String.self, forKey: .codeClub)
prixEspece = try container.decodeIfPresent(Int.self, forKey: .prixEspece)
// Custom decoding for dateDebut
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateDebut) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateDebut = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateFin
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateFin) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateFin = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateValidation
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateValidation) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateValidation = dateFormatter.date(from: dateString)
}
}
}
private enum DateKeys: String, CodingKey {
case date
}
var dayPeriod: DayPeriod {
if let dateDebut {
let day = dateDebut.get(.weekday)
@ -228,47 +126,10 @@ struct FederalTournament: Identifiable, Codable, Hashable {
?? []
}
var federalClub: FederalClub? {
if let codeClub {
return FederalClub(federalClubCode: codeClub, federalClubName: clubLabel())
} else {
return nil
}
}
var shareMessage: String {
[libelle, dateDebut?.formatted(date: .complete, time: .omitted)].compactMap({$0}).joined(separator: "\n") + "\n"
}
var sharePartnerMessage: String {
["Je nous ai inscris au tournoi suivant : ",
libelle,
dateDebut?.formatted(date: .complete, time: .omitted),
"message preparé par Padel Club",
URLs.appStore.rawValue
].compactMap({$0}).joined(separator: "\n") + "\n"
}
func calendarNoteMessage() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: "\n")
}
var japMessage: String {
[nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, japPhoneNumber].compactMap({$0}).joined(separator: ";")
}
func umpireLabel() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).map({ $0.lowercased().capitalized }).joined(separator: " ")
}
func phoneLabel() -> String {
[installation?.telephone].compactMap({$0}).joined(separator: " ")
}
func mailLabel() -> String {
[courrielEngagement].compactMap({$0}).joined(separator: " ")
}
func validForSearch(_ searchText: String, scope: FederalTournamentSearchScope) -> Bool {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
@ -295,25 +156,18 @@ extension FederalTournament: FederalTournamentHolder {
// var importedId: Int { id }
var holderId: String { id.string }
func clubLabel() -> String {
nomClub ?? villeEngagement ?? installation?.nom ?? ""
}
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
func subtitleLabel() -> String {
""
}
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String {
build.level.localizedLevelLabel(displayStyle)
}
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool {
true
}
}
// MARK: - CategorieAge
struct CategorieAge: Codable, Hashable {
struct CategorieAge: Codable {
var ageJoueurMin, ageMin, ageJoueurMax, ageRechercheMax: Int?
var categoriesAgeTypePratique: [CategoriesAgeTypePratique]?
var ageMax: Int?
@ -325,28 +179,35 @@ struct CategorieAge: Codable, Hashable {
var tournamentAge: FederalTournamentAge? {
if let id {
return FederalTournamentAge(rawValue: id) ?? .senior
return FederalTournamentAge(rawValue: id)
}
if let libelle {
return FederalTournamentAge.allCases.first(where: { $0.localizedFederalAgeLabel().localizedCaseInsensitiveContains(libelle) }) ?? .senior
return FederalTournamentAge.allCases.first(where: { $0.localizedLabel().localizedCaseInsensitiveContains(libelle) })
}
return .senior
return nil
}
}
// MARK: - CategoriesAgeTypePratique
struct CategoriesAgeTypePratique: Codable, Hashable {
struct CategoriesAgeTypePratique: Codable {
var id: ID?
}
// MARK: - ID
struct ID: Codable, Hashable {
var typePratique: String?
struct ID: Codable {
var typePratique: TypePratique?
var idCategorieAge: Int?
}
enum TypePratique: String, Codable {
case beach = "BEACH"
case padel = "PADEL"
case tennis = "TENNIS"
case pickle = "PICKLE"
}
// MARK: - CategorieTournoi
struct CategorieTournoi: Codable, Hashable {
struct CategorieTournoi: Codable {
var code, codeTaxe: String?
var compteurGda: CompteurGda?
var libelle, niveauHierarchique: String?
@ -354,14 +215,14 @@ struct CategorieTournoi: Codable, Hashable {
}
// MARK: - CompteurGda
struct CompteurGda: Codable, Hashable {
struct CompteurGda: Codable {
var classementMax: Classement?
var libelle: String?
var classementMin: Classement?
}
// MARK: - Classement
struct Classement: Codable, Hashable {
struct Classement: Codable {
var nature, libelle: String?
var serie: Serie?
var sexe: String?
@ -371,18 +232,18 @@ struct Classement: Codable, Hashable {
}
// MARK: - Serie
struct Serie: Codable, Hashable {
struct Serie: Codable {
var code, libelle: String?
var valide: Bool?
var sexe: String?
var tournamentCategory: TournamentCategory? {
TournamentCategory.allCases.first(where: { $0.requestLabel == code }) ?? .men
TournamentCategory.allCases.first(where: { $0.requestLabel == code })
}
}
// MARK: - Epreuve
struct Epreuve: Codable, Hashable {
struct Epreuve: Codable {
var inscriptionEnLigneEnCours: Bool?
var categorieAge: CategorieAge?
var typeEpreuve: TypeEpreuve?
@ -419,7 +280,7 @@ struct Epreuve: Codable, Hashable {
}
// MARK: - TypeEpreuve
struct TypeEpreuve: Codable, Hashable {
struct TypeEpreuve: Codable {
let code: String?
let delai: Int?
let libelle: String?
@ -430,19 +291,19 @@ struct TypeEpreuve: Codable, Hashable {
var tournamentLevel: TournamentLevel? {
if let code, let value = Int(code.removingFirstCharacter) {
return TournamentLevel(rawValue: value) ?? .p100
return TournamentLevel(rawValue: value)
}
return .p100
return nil
}
}
// MARK: - BorneAnneesNaissance
struct BorneAnneesNaissance: Codable, Hashable {
struct BorneAnneesNaissance: Codable {
var min, max: Int?
}
// MARK: - Installation
struct Installation: Codable, Hashable {
struct Installation: Codable {
var ville: String?
var lng: Double?
var surfaces: [JSONAny]?
@ -457,7 +318,7 @@ struct Installation: Codable, Hashable {
}
// MARK: - JugeArbitre
struct JugeArbitre: Codable, Hashable {
struct JugeArbitre: Codable {
var idCRM, id: Int?
var nom, prenom: String?
@ -468,7 +329,7 @@ struct JugeArbitre: Codable, Hashable {
}
// MARK: - ModeleDeBalle
struct ModeleDeBalle: Codable, Hashable {
struct ModeleDeBalle: Codable {
var libelle: String?
var marqueDeBalle: MarqueDeBalle?
var id: Int?
@ -476,7 +337,7 @@ struct ModeleDeBalle: Codable, Hashable {
}
// MARK: - MarqueDeBalle
struct MarqueDeBalle: Codable, Hashable {
struct MarqueDeBalle: Codable {
var id: Int?
var valide: Bool?
var marque: String?
@ -529,13 +390,9 @@ class JSONCodingKey: CodingKey {
}
}
class JSONAny: Codable, Hashable, Equatable {
var value: Any
class JSONAny: Codable {
init() {
self.value = ()
}
let value: Any
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
@ -726,70 +583,4 @@ class JSONAny: Codable, Hashable, Equatable {
try JSONAny.encode(to: &container, value: self.value)
}
}
public static func == (lhs: JSONAny, rhs: JSONAny) -> Bool {
switch (lhs.value, rhs.value) {
case (let l as Bool, let r as Bool): return l == r
case (let l as Int64, let r as Int64): return l == r
case (let l as Double, let r as Double): return l == r
case (let l as String, let r as String): return l == r
case (let l as JSONNull, let r as JSONNull): return true
case (let l as [Any], let r as [Any]):
guard l.count == r.count else { return false }
return zip(l, r).allSatisfy { (a, b) in
// Recursively wrap in JSONAny for comparison
JSONAny(value: a) == JSONAny(value: b)
}
case (let l as [String: Any], let r as [String: Any]):
guard l.count == r.count else { return false }
for (key, lVal) in l {
guard let rVal = r[key], JSONAny(value: lVal) == JSONAny(value: rVal) else { return false }
}
return true
default:
return false
}
}
public func hash(into hasher: inout Hasher) {
switch value {
case let v as Bool:
hasher.combine(0)
hasher.combine(v)
case let v as Int64:
hasher.combine(1)
hasher.combine(v)
case let v as Double:
hasher.combine(2)
hasher.combine(v)
case let v as String:
hasher.combine(3)
hasher.combine(v)
case is JSONNull:
hasher.combine(4)
case let v as [Any]:
hasher.combine(5)
for elem in v {
JSONAny(value: elem).hash(into: &hasher)
}
case let v as [String: Any]:
hasher.combine(6)
// Order of hashing dictionary keys shouldn't matter
for key in v.keys.sorted() {
hasher.combine(key)
if let val = v[key] {
JSONAny(value: val).hash(into: &hasher)
}
}
default:
hasher.combine(-1)
}
}
// Helper init for internal use
convenience init(value: Any) {
self.init()
self.value = value
}
}

@ -6,23 +6,19 @@
//
import Foundation
import PadelClubData
protocol FederalTournamentHolder {
var holderId: String { get }
var startDate: Date { get }
var endDate: Date? { get }
var codeClub: String? { get }
var tournaments: [any TournamentBuildHolder] { get }
func clubLabel() -> String
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String
func subtitleLabel() -> String
var dayDuration: Int { get }
var dayPeriod: DayPeriod { get }
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool
}
extension FederalTournamentHolder {
func durationLabel() -> String {
switch dayDuration {
case 1:

@ -6,7 +6,6 @@
//
import Foundation
import SwiftUI
protocol PlayerHolder {
@ -25,9 +24,6 @@ protocol PlayerHolder {
var computedAge: Int? { get }
func getAssimilatedAsMaleRank() -> Int?
func isNotFromCurrentDate() -> Bool
func getBirthYear() -> Int?
func getProgression() -> Int
func getComputedRank() -> Int?
}
extension PlayerHolder {
@ -42,16 +38,4 @@ extension PlayerHolder {
func isAnonymous() -> Bool {
getFirstName().isEmpty && getLastName().isEmpty
}
func getProgressionColor(progression: Int) -> Color {
switch progression {
case _ where progression > 0:
return Color.green
case _ where progression < 0:
return Color.logoRed
default:
return Color.primary
}
}
}

@ -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 {
}

@ -2,7 +2,7 @@
// MonthData+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
// Created by Laurent Morvillier on 27/08/2024.
//
import Foundation
@ -15,35 +15,29 @@ extension MonthData {
let fileURL = SourceFileManager.shared.allFiles(true).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
print("calculateCurrentUnrankedValues", fromDate.monthYearFormatted, fileURL?.path())
let fftImportingUncomplete = fileURL?.fftImportingUncomplete()
var fftImportingAnonymous = fileURL?.fftImportingAnonymous()
let fftImportingMaleUnrankValue = fileURL?.fftImportingMaleUnrankValue()
let femaleFileURL = SourceFileManager.shared.allFiles(false).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
let femaleFftImportingMaleUnrankValue = femaleFileURL?.fftImportingMaleUnrankValue()
let femaleFftImportingUncomplete = femaleFileURL?.fftImportingUncomplete()
let incompleteMode = fftImportingUncomplete != nil
let lastDataSourceMaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: true)
let lastDataSourceFemaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: false)
if fftImportingAnonymous == nil {
fftImportingAnonymous = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
}
let anonymousCount: Int? = fftImportingAnonymous
let anonymousCount = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
await MainActor.run {
let lastDataSource = URL.importDateFormatter.string(from: fromDate)
let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource)
currentMonthData.dataModelIdentifier = PersistenceController.getModelVersion()
currentMonthData.fileModelIdentifier = fileURL?.fileModelIdentifier()
currentMonthData._updateCreationDate()
currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0
currentMonthData.incompleteMode = incompleteMode
currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1
currentMonthData.femaleUnrankedValue = incompleteMode ? femaleFftImportingMaleUnrankValue : lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = incompleteMode ? femaleFftImportingUncomplete : lastDataSourceFemaleUnranked?.1
currentMonthData.femaleUnrankedValue = lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = lastDataSourceFemaleUnranked?.1
currentMonthData.anonymousCount = anonymousCount
DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
do {
try DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
} catch {
Logger.error(error)
}
}
}
}

@ -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 = ["", "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);">
&nbsp;{{roundLabel}}
<div>{{formatLabel}}</div>
</li>
<li class="spacer">&nbsp;{{roundLabel}}</li>
{{match-template}}
</ul>

@ -82,7 +82,6 @@ body{
<caption>
<h2>{{bracketTitle}}</h2>
<h3>{{bracketStartDate}}</h3>
<h3>{{formatLabel}}</h3>
</caption>
<tr>
<th scope="col" style="visibility:hidden"></th>

@ -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">&nbsp;</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>

@ -9,7 +9,6 @@
flex-direction:row;
padding: 1%;
}
.round{
display:flex;
flex-direction:column;
@ -28,7 +27,7 @@
.round .spacer{ flex-grow:1;
font-size:24px;
text-align: center;
color: #000000;
color: #bbb;
font-style:italic;
}
.round .spacer:first-child,
@ -66,7 +65,7 @@
li.game-spacer{
border-right:2px solid #4f7a38;
min-height:{{minHeight}}px;
min-height:156px;
text-align: right;
display : flex;
justify-content: center;
@ -92,40 +91,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
.game {
/* Ensure the game container is a positioning context for the overlay */
position: relative;
/* Add any other existing styles for your game list items */
}
.match-description-overlay {
/* Position the overlay directly on top of the game item */
position: absolute;
top: 0;
left: 0;
transform: translateY(100%);
width: 100%;
height: 100%;
display: flex; /* Enable flexbox for centering */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically (if needed) */
font-size: 1em; /* Optional: Adjust font size */
/* Add any other desired styling for the overlay */
}
.center-match-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.8em;
white-space: nowrap; /* Prevents text from wrapping */
}
</style>
</head>
<body>
<h3 style="visibility:{{titleHidden}}">{{tournamentTitle}} - {{tournamentStartDate}}</h3>
<h1>{{tournamentTitle}}</h1>
<main id="tournament">
{{brackets}}
</main>

@ -33,5 +33,7 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>

@ -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")
}
}
}
}

@ -8,7 +8,6 @@
import SwiftUI
import LeStorage
import TipKit
import PadelClubData
@main
struct PadelClubApp: App {
@ -18,10 +17,6 @@ struct PadelClubApp: App {
@StateObject var dataStore = DataStore.shared
@State private var registrationError: RegistrationError? = nil
@State private var importObserverViewModel = ImportObserver()
@State private var showDisconnectionAlert: Bool = false
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var requiredVersion: String? = nil
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@ -53,109 +48,48 @@ struct PadelClubApp: App {
let dictionary = Bundle.main.infoDictionary!
let version = dictionary["CFBundleShortVersionString"] as! String
let build = dictionary["CFBundleVersion"] as! String
#if DEBUG
return "\(version) (\(build)) Debug"
#elseif TESTFLIGHT
return "\(version) (\(build)) TestFlight"
#elseif PRODTEST
return "\(version) (\(build)) ProdTest"
#else
return "\(version) (\(build))"
#endif
}
var body: some Scene {
WindowGroup {
if let requiredVersion {
DownloadNewVersionView(version: requiredVersion)
} else {
MainView()
.environment(\.horizontalSizeClass, .compact)
.alert(isPresented: presentError, error: registrationError) {
Button("Contactez-nous") {
_openMail()
}
Button("Annuler", role: .cancel) {
registrationError = nil
}
MainView()
.alert(isPresented: presentError, error: registrationError) {
Button("Contactez-nous") {
_openMail()
}
.onOpenURL { url in
#if targetEnvironment(simulator)
#else
_handleIncomingURL(url)
#endif
Button("Annuler", role: .cancel) {
registrationError = nil
}
.environmentObject(networkMonitor)
.environmentObject(dataStore)
.environment(importObserverViewModel)
.environment(navigationViewModel)
.accentColor(.master)
.onAppear {
self._checkVersion()
if ManualPatcher.patchIfPossible(.disconnect) == true {
self.showDisconnectionAlert = true
}
#if DEBUG
print("Running in Debug mode")
#elseif TESTFLIGHT
print("Running in TestFlight mode")
#elseif PRODTEST
print("Running in ProdTest mode")
}
.onOpenURL { url in
#if targetEnvironment(simulator)
#else
print("Running in Release mode")
_handleIncomingURL(url)
#endif
print(URLs.main.url)
networkMonitor.checkConnection()
self._onAppear()
print(PersistenceController.getModelVersion())
}
.alert(isPresented: self.$showDisconnectionAlert, content: {
Alert(title: Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données."))
})
.task {
// try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
.environment(\.managedObjectContext, persistenceController.localContainer.viewContext)
}
}
}
fileprivate func _checkVersion() {
Task.detached(priority: .high) {
if let requiredVersion = await self._retrieveRequiredVersion() {
let cleanedRequired = requiredVersion.replacingOccurrences(of: "\n", with: "")
Logger.log(">>> REQUIRED VERSION = \(requiredVersion)")
if let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
await MainActor.run {
if VersionComparator.compare(cleanedRequired, currentVersion) == 1 {
self.requiredVersion = cleanedRequired
}
}
}
}
.environmentObject(networkMonitor)
.environmentObject(dataStore)
.environment(importObserverViewModel)
.environment(navigationViewModel)
.accentColor(.master)
.onAppear {
networkMonitor.checkConnection()
self._onAppear()
}
.task {
//try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
.environment(\.managedObjectContext, persistenceController.localContainer.viewContext)
}
}
fileprivate func _retrieveRequiredVersion() async -> String? {
let requiredVersionURL = URLs.main.extend(path: "static/misc/required-version.txt")
do {
let (data, _) = try await URLSession.shared.data(from: requiredVersionURL)
return String(data: data, encoding: .utf8)
} catch {
Logger.log("Error fetching required version: \(error)")
return nil
}
}
private func _handleIncomingURL(_ url: URL) {
// Parse the URL
let pathComponents = url.pathComponents
@ -193,11 +127,11 @@ struct PadelClubApp: App {
navigationViewModel.selectedTab = .umpire
}
if navigationViewModel.accountPath.isEmpty {
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
} else if navigationViewModel.accountPath.last! != .login {
navigationViewModel.accountPath.removeAll()
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
if navigationViewModel.umpirePath.isEmpty {
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
} else if navigationViewModel.umpirePath.last! != .login {
navigationViewModel.umpirePath.removeAll()
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
}
}
}.resume()
@ -219,59 +153,3 @@ struct PadelClubApp: App {
}
}
}
struct DownloadNewVersionView: View {
var version: String
var body: some View {
VStack {
// AngledStripesBackground()
Spacer()
VStack(spacing: 20.0) {
Text("Veuillez télécharger la nouvelle version de Padel Club pour continuer à vous servir de l'app !")
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding()
.background(.logoRed)
.clipShape(.buttonBorder)
// .padding(32.0)
VStack(alignment: .center, spacing: 0.0
) {
Text("Version \(self.version)")
.fontWeight(.bold)
Image(systemName: "square.and.arrow.down").font(.title)
}.padding().background(.logoYellow)
.clipShape(.buttonBorder)
}
.frame(maxWidth: .infinity)
.foregroundStyle(.logoBackground)
.fontWeight(.medium)
.multilineTextAlignment(.center)
.padding(.horizontal, 36.0)
Image("logo").padding(.vertical, 50.0)
Spacer()
}
.background(.logoBackground)
.onTapGesture {
UIApplication.shared.open(URLs.appStore.url)
}
}
}
struct DisconnectionAlertView: View {
var body: some View {
Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données.").multilineTextAlignment(.center).padding()
}
}
#Preview {
DownloadNewVersionView(version: "1.2")
}

Binary file not shown.

@ -1,36 +1,11 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "2055C391",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "129.0",
"familyShareable" : false,
"internalID" : "6751947241",
"localizations" : [
{
"description" : "Achetez 10 tournois",
"displayName" : "Pack de 10 tournois",
"locale" : "fr"
}
],
"productID" : "app.padelclub.tournament.unit.10",
"referenceName" : "Pack de 10 tournois",
"type" : "Consumable"
},
{
"displayPrice" : "17.0",
"displayPrice" : "14.0",
"familyShareable" : false,
"internalID" : "6484163993",
"localizations" : [
@ -47,53 +22,57 @@
],
"settings" : {
"_applicationInternalID" : "6484163558",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_compatibilityTimeRate" : {
"3" : 6
},
"_developerTeamID" : "BQ3Y44M3Q6",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 779705033.96878397,
"_locale" : "fr",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "FRA",
"_lastSynchronizedDate" : 735034894.72550702,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
@ -110,15 +89,7 @@
"subscriptions" : [
{
"adHocOffers" : [
{
"displayPrice" : "45.0",
"internalID" : "1A02CDB5",
"numberOfPeriods" : 12,
"offerID" : "PRICE50",
"paymentMode" : "payAsYouGo",
"referenceName" : "ancien prix 50",
"subscriptionPeriod" : "P1M"
}
],
"codeOffers" : [
@ -139,10 +110,7 @@
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Five",
"subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
"type" : "RecurringSubscription"
},
{
"adHocOffers" : [
@ -167,16 +135,13 @@
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Unlimited",
"subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
"type" : "RecurringSubscription"
}
]
}
],
"version" : {
"major" : 4,
"major" : 3,
"minor" : 0
}
}

@ -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()
}
}

@ -8,7 +8,6 @@
import Foundation
import LeStorage
import SwiftUI
import PadelClubData
enum FileImportManagerError: LocalizedError {
case unknownFormat
@ -29,6 +28,9 @@ class ImportObserver {
func currentlyImportingLabel() -> String {
guard let currentImportDate else { return "import en cours" }
if URL.importDateFormatter.string(from: currentImportDate) == "07-2024" {
return "consolidation des données"
}
return "import " + currentImportDate.monthYearFormatted
}
@ -42,38 +44,31 @@ class ImportObserver {
class FileImportManager {
static let shared = FileImportManager()
func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) {
let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")]
var playersLeft = Dictionary(uniqueKeysWithValues: players.map { ($0.license, $0) })
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach { url in
if playersLeft.isEmpty { return }
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
let federalPlayersDict = Dictionary(uniqueKeysWithValues: federalPlayers.map { ($0.license, $0) })
for (license, importedPlayer) in playersLeft {
guard let federalPlayer = federalPlayersDict[license] else { continue }
var playersLeft = players
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach({ url in
if playersLeft.isEmpty == false {
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
var lastName = federalPlayer.lastName
var firstName = federalPlayer.firstName
lastName.replace(characters: replacementsCharacters)
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
playersLeft.removeValue(forKey: license) // Remove processed player
playersLeft.forEach { importedPlayer in
if let federalPlayer = federalPlayers.first(where: { $0.license == importedPlayer.license }) {
var lastName = federalPlayer.lastName
lastName.replace(characters: replacementsCharacters)
var firstName = federalPlayer.firstName
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
}
}
playersLeft.removeAll(where: { $0.lastName.isEmpty == false })
}
}
players = Array(playersLeft.values)
})
}
func foundInWomenData(license: String?) -> Bool {
guard let license = license?.strippedLicense else {
return false
@ -132,16 +127,14 @@ class FileImportManager {
let weight: Int
let tournamentCategory: TournamentCategory
let tournamentAgeCategory: FederalTournamentAge
let tournamentLevel: TournamentLevel
let previousTeam: TeamRegistration?
var registrationDate: Date? = nil
var name: String? = nil
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, tournamentLevel: TournamentLevel, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
self.players = Set(players)
self.tournamentCategory = tournamentCategory
self.tournamentAgeCategory = tournamentAgeCategory
self.tournamentLevel = tournamentLevel
self.name = name
self.previousTeam = previousTeam
if players.count < 2 {
@ -154,7 +147,7 @@ class FileImportManager {
}
let significantPlayerCount = 2
let pl = players.prefix(significantPlayerCount).map { $0.computedRank }
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 92_327 : 10_000) }).prefix(significantPlayerCount)
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 70_000 : 10_000) }).prefix(significantPlayerCount)
self.weight = pl.reduce(0,+) + missingPl.reduce(0,+)
} else {
self.weight = players.map { $0.computedRank }.reduce(0,+)
@ -185,7 +178,7 @@ class FileImportManager {
static let FFT_ASSIMILATION_WOMAN_IN_MAN = "A calculer selon la pondération en vigueur"
func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool, chunkByParameter: Bool) async throws -> [TeamHolder] {
func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool) async throws -> [TeamHolder] {
switch fileProvider {
case .frenchFederation:
@ -193,9 +186,9 @@ class FileImportManager {
case .padelClub:
return await _getPadelClubTeams(from: fileContent, tournament: tournament)
case .custom:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: false, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: false, tournament: tournament)
case .customAutoSearch:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: true, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: true, tournament: tournament)
}
}
@ -285,9 +278,9 @@ class FileImportManager {
FederalTournamentAge.allCases.first(where: { $0.importingRawValue.canonicalVersion == ageCategory.canonicalVersion }) ?? .senior
}
let resultOne = Array(dataOne.dropFirst(3).dropLast(3))
let resultTwo = Array(dataTwo.dropFirst(3).dropLast(3))
let sexUnknown: Bool = (dataOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (dataTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
let resultOne = Array(dataOne.dropFirst(3).dropLast())
let resultTwo = Array(dataTwo.dropFirst(3).dropLast())
let sexUnknown: Bool = (resultOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (resultTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
var sexPlayerOne : Int {
switch tournamentCategory {
@ -309,14 +302,12 @@ class FileImportManager {
if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled {
let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -372,14 +363,12 @@ class FileImportManager {
let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -410,7 +399,6 @@ class FileImportManager {
let registeredPlayers = found?.map({ importedPlayer in
let player = PlayerRegistration(importedPlayer: importedPlayer)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
return player
})
if let registeredPlayers, registeredPlayers.isEmpty == false {
@ -428,7 +416,7 @@ class FileImportManager {
return nil
}
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
results.append(team)
}
}
@ -436,7 +424,7 @@ class FileImportManager {
return results
}
private func _getPadelBusinessLeagueTeams(from fileContent: String, chunkByParameter: Bool, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
private func _getPadelBusinessLeagueTeams(from fileContent: String, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
let lines = fileContent.replacingOccurrences(of: "\"", with: "").components(separatedBy: "\n")
guard let firstLine = lines.first else { return [] }
var separator = ","
@ -446,84 +434,39 @@ class FileImportManager {
let fetchRequest = ImportedPlayer.fetchRequest()
let federalContext = PersistenceController.shared.localContainer.viewContext
var chunks: [[String]] = []
if chunkByParameter {
chunks = lines.chunked(byParameterAt: 1)
} else {
chunks = lines.chunked(into: 2)
}
let results = chunks.map { team in
let results: [TeamHolder] = lines.chunked(into: 2).map { team in
var teamName: String? = nil
let players = team.map { player in
let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerSexType = data[safe: 0] == "f" ? PlayerSexType.female : PlayerSexType.male
let lastName : String = data[safe: 2]?.trimmed ?? ""
let firstName : String = data[safe: 3]?.trimmed ?? ""
let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed
}
let phoneNumber : String? = data[safe: 4]?.trimmed.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).prefixTrimmed(50)
let email : String? = data[safe: 5]?.prefixTrimmed(50)
let phoneNumber : String? = data[safe: 4]?.trimmed
let email : String? = data[safe: 5]?.trimmed
let rank : Int? = data[safe: 6]?.trimmed.toInt()
let licenceId : String? = data[safe: 7]?.prefixTrimmed(50)
let club : String? = data[safe: 8]?.prefixTrimmed(200)
let licenceId : String? = data[safe: 7]?.trimmed
let club : String? = data[safe: 8]?.trimmed
let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName)
fetchRequest.predicate = predicate
let found = try? federalContext.fetch(fetchRequest).first
if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player
} else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
if rank == nil, autoSearch {
player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
}
return player
}
}
return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: nil, name: teamName, tournament: tournament)
return TeamHolder(players: players, tournamentCategory: .men, tournamentAgeCategory: .senior, previousTeam: nil, name: teamName, tournament: tournament)
}
return results
}
}
extension Array where Element == String {
/// Groups the array of CSV lines based on the same value at the specified column index.
/// If no key is found, it defaults to chunking the array into groups of 2 lines.
/// - Parameter index: The index of the CSV column to group by.
/// - Returns: An array of arrays, where each inner array contains lines grouped by the CSV parameter or by default chunks of 2.
func chunked(byParameterAt index: Int) -> [[String]] {
var groups: [String: [String]] = [:]
for line in self {
let columns = line.split(separator: ";", omittingEmptySubsequences: false).map { String($0) }
if index < columns.count {
let key = columns[index]
if groups[key] == nil {
groups[key] = []
}
groups[key]?.append(line)
} else {
// Handle out-of-bounds by continuing
print("Warning: Index \(index) out of bounds for line: \(line)")
}
}
// If no valid groups found, chunk into groups of 2 lines
if groups.isEmpty {
return self.chunked(into: 2)
} else {
// Append groups by parameter value, converting groups.values into an array of arrays
return groups.map { $0.value }
}
}
}

@ -9,7 +9,6 @@ import Foundation
import UIKit
import WebKit
import PDFKit
import PadelClubData
class HtmlGenerator: ObservableObject {
@ -25,10 +24,6 @@ class HtmlGenerator: ObservableObject {
@Published var displayHeads: Bool = false
@Published var groupStageIsReady: Bool = false
@Published var displayRank: Bool = false
@Published var displayTeamIndex: Bool = false
@Published var displayScore: Bool = false
@Published var displayPlannedDate: Bool = true
private var pdfDocument: PDFDocument = PDFDocument()
private var rects: [CGRect] = []
private var completionHandler: ((Result<Bool, Error>) -> ())?
@ -63,8 +58,6 @@ class HtmlGenerator: ObservableObject {
func generateWebView(webView: WKWebView) {
self.webView = webView
#if targetEnvironment(simulator)
#else
self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
print("evaluateJavaScript", "readystage", complete, error)
if complete != nil {
@ -81,12 +74,9 @@ class HtmlGenerator: ObservableObject {
})
}
})
#endif
}
func generateGroupStage(webView: WKWebView) {
#if targetEnvironment(simulator)
#else
webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
if complete != nil {
webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
@ -121,7 +111,7 @@ class HtmlGenerator: ObservableObject {
})
}
})
#endif
}
func buildPDF() {
@ -155,8 +145,6 @@ class HtmlGenerator: ObservableObject {
}
func createPage() {
#if targetEnvironment(simulator)
#else
let config = WKPDFConfiguration()
config.rect = rects[pdfDocument.pageCount]
webView.createPDF(configuration: config){ result in
@ -175,21 +163,16 @@ class HtmlGenerator: ObservableObject {
self.completionHandler?(.failure(error))
}
}
#endif
}
func generateHtml() -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.template(tournament: tournament).html(options: options)
HtmlService.template(tournament: tournament).html(headName: displayHeads, withRank: displayRank, withScore: false)
}
func generateLoserBracketHtml(upperRound: Round) -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(options: options)
}
var options: HtmlOptions {
HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate, includeLoserBracket: includeLoserBracket)
HtmlService.loserBracket(upperRound: upperRound).html(headName: displayHeads, withRank: displayRank, withScore: false)
}
var pdfURL: URL? {
@ -203,7 +186,7 @@ class HtmlGenerator: ObservableObject {
.day()
.dateSeparator(.dash))
let name = tournament.tournamentLevel.localizedLevelLabel() + "-" + tournament.tournamentCategory.importingRawValue
let name = tournament.tournamentLevel.localizedLabel() + "-" + tournament.tournamentCategory.importingRawValue
return pdfFolderURL.appendingPathComponent(stringDate + "-" + name + ".pdf")
}

@ -6,39 +6,12 @@
//
import Foundation
import PadelClubData
struct HtmlOptions {
let headName: Bool
let withRank: Bool
let withTeamIndex: Bool
let withScore: Bool
let withPlannedDate: Bool
let includeLoserBracket: Bool
// Default initializer with all options defaulting to true
init(
headName: Bool = true,
withRank: Bool = true,
withTeamIndex: Bool = true,
withScore: Bool = true,
withPlannedDate: Bool = true,
includeLoserBracket: Bool = false
) {
self.headName = headName
self.withRank = withRank
self.withTeamIndex = withTeamIndex
self.withScore = withScore
self.withPlannedDate = withPlannedDate
self.includeLoserBracket = includeLoserBracket
}
}
enum HtmlService {
case template(tournament: Tournament)
case bracket(round: Round)
case loserBracket(upperRound: Round, hideTitle: Bool)
case loserBracket(upperRound: Round)
case match(match: Match)
case player(entrant: TeamRegistration)
case hiddenPlayer
@ -77,7 +50,7 @@ enum HtmlService {
}
}
func html(options: HtmlOptions = HtmlOptions()) -> String {
func html(headName: Bool, withRank: Bool, withScore: Bool) -> String {
guard let file = Bundle.main.path(forResource: self.fileName, ofType: "html") else {
fatalError()
}
@ -96,12 +69,12 @@ enum HtmlService {
}
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: bracket.tournamentObject()!.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{bracketTitle}}", with: bracket.groupStageTitle())
template = template.replacingOccurrences(of: "{{formatLabel}}", with: bracket.matchFormat.formatTitle())
var col = ""
var row = ""
bracket.teams().forEach { entrant in
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(options: options))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(options: options))
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(headName: headName, withRank: withRank, withScore: withScore))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(headName: headName, withRank: withRank, withScore: withScore))
}
template = template.replacingOccurrences(of: "{{teamsCol}}", with: col)
template = template.replacingOccurrences(of: "{{teamsRow}}", with: row)
@ -109,15 +82,9 @@ enum HtmlService {
return template
case .groupstageEntrant(let entrant):
var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.seedIndex() ?? "")
}
if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -129,7 +96,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -141,7 +108,7 @@ enum HtmlService {
return template
case .groupstageRow(let entrant, let teamsPerBracket):
var template = html
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(options: options))
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(headName: headName, withRank: withRank, withScore: withScore))
var scores = ""
(0..<teamsPerBracket).forEach { index in
@ -150,38 +117,31 @@ enum HtmlService {
if shouldHide == false {
match = entrant.groupStageObject()?.matchPlayed(by: entrant.groupStagePosition!, againstPosition: index)
}
scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(options: options))
scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(headName: headName, withRank: withRank, withScore: withScore))
}
template = template.replacingOccurrences(of: "{{scores}}", with: scores)
return template
case .groupstageColumn(let entrant, let position):
var template = html
template = template.replacingOccurrences(of: "{{tablePosition}}", with: position)
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(options: options))
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(headName: headName, withRank: withRank, withScore: withScore))
return template
case .groupstageScore(let match, let shouldHide):
var template = html
if match == nil || options.withScore == false {
if match == nil || withScore == false {
template = template.replacingOccurrences(of: "{{winner}}", with: "")
template = template.replacingOccurrences(of: "{{score}}", with: "")
} else if let match, let winner = match.winner() {
template = template.replacingOccurrences(of: "{{winner}}", with: winner.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match.scoreLabel())
} else {
template = template.replacingOccurrences(of: "{{winner}}", with: match!.winner()!.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match!.scoreLabel())
}
template = template.replacingOccurrences(of: "{{hide}}", with: shouldHide ? "hide" : "")
return template
case .player(let entrant):
var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.formattedSeed())
}
if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -193,7 +153,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -204,33 +164,18 @@ enum HtmlService {
}
return template
case .hiddenPlayer:
var template = html + html
if options.withTeamIndex {
template += html
}
return template
return html + html
case .match(let match):
var template = html
if options.withPlannedDate, let plannedStartDate = match.plannedStartDate {
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: plannedStartDate.localizedDate())
} else {
}
if let entrantOne = match.team(.one) {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(options: options))
if options.withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: [top.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(headName: headName, withRank: withRank, withScore: withScore))
} else {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(options: options))
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
}
if let entrantTwo = match.team(.two) {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(options: options))
if options.withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: [bottom.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withScore: withScore))
} else {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(options: options))
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
}
if match.disabled {
template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden")
@ -243,45 +188,27 @@ enum HtmlService {
} else if match.teamWon(atPosition: .two) == true {
template = template.replacingOccurrences(of: "{{entrantTwoWon}}", with: "winner")
}
// template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n"))
template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: "")
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescription}}", with: "")
return template
case .bracket(let round):
var template = ""
var bracket = ""
for (_, match) in round._matches().enumerated() {
template = template.appending(HtmlService.match(match: match).html(options: options))
template = template.appending(HtmlService.match(match: match).html(headName: headName, withRank: withRank, withScore: withScore))
}
bracket = html.replacingOccurrences(of: "{{match-template}}", with: template)
bracket = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle())
bracket = bracket.replacingOccurrences(of: "{{formatLabel}}", with: round.matchFormat.formatTitle())
return bracket
case .loserBracket(let upperRound, let hideTitle):
case .loserBracket(let upperRound):
var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: upperRound.correspondingLoserRoundTitle())
if let tournamentStartDate = upperRound.initialStartDate()?.localizedDate() {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournamentStartDate)
} else {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: "")
}
template = template.replacingOccurrences(of: "{{titleHidden}}", with: hideTitle ? "hidden" : "")
var brackets = ""
for round in upperRound.loserRounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
}
let winnerName = ""
var winnerName = ""
let winner = """
<ul class="round" scope="last">
<li class="spacer">&nbsp;</li>
@ -294,35 +221,18 @@ enum HtmlService {
brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
for round in upperRound.loserRounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
return template
case .template(let tournament):
var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.title))
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournament.formattedDate())
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.short))
var brackets = ""
for round in tournament.rounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if options.includeLoserBracket {
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
}
var winnerName = ""
if let tournamentWinner = tournament.tournamentWinner() {
winnerName = HtmlService.player(entrant: tournamentWinner).html(options: options)
winnerName = HtmlService.player(entrant: tournamentWinner).html(headName: headName, withRank: withRank, withScore: withScore)
}
let winner = """
<ul class="round" scope="last">
@ -336,16 +246,6 @@ enum HtmlService {
brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
if options.includeLoserBracket {
for round in tournament.rounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
}
return template
}
}

@ -16,18 +16,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published var postalCode: String?
@Published var requestStarted: Bool = false
@Published var userReadableCityOrZipcode: String = ""
@Published var lastError: LocalizedError? = nil
enum LocationError: LocalizedError {
case unknownError(error: Error)
var errorDescription: String? {
switch self {
case .unknownError(let error):
return "Padel Club n'a pas réussi à vous localiser."
}
}
}
@Published var lastError: Error? = nil
override init() {
super.init()
@ -37,8 +26,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func requestLocation() {
lastError = nil
manager.requestLocation()
city = nil
location = nil
requestStarted = true
}
@ -62,7 +49,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("locationManager didFailWithError", error)
requestStarted = false
self.lastError = LocationError.unknownError(error: error)
self.lastError = error
}
func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) {

@ -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)")
}
}
}

@ -7,7 +7,6 @@
import Foundation
import CoreLocation
import PadelClubData
class NetworkFederalService {
struct HttpCommand: Decodable {
@ -34,40 +33,19 @@ class NetworkFederalService {
return decoder
}()
func runTenupTask<T: Decodable>(request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
// Print request info
print("Request: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
// Print response status
if let httpResponse = response as? HTTPURLResponse {
print("Status code: \(httpResponse.statusCode)")
}
// Print JSON data before decoding
if let jsonObject = try? JSONSerialization.jsonObject(with: data) {
//print("Response JSON: \(jsonObject)")
} else {
print("Response is not a valid JSON")
// Try to print as string if not JSON
if let stringResponse = String(data: data, encoding: .utf8) {
print("Response as string: \(stringResponse)")
func runTenupTask<T:Decodable>(request: URLRequest) async throws -> T {
let task = try await URLSession.shared.data(for: request)
if request.httpMethod == "PUT" {
print("tried PUT: \(request.url!)")
if let urlResponse = task.1 as? HTTPURLResponse {
print(urlResponse.statusCode)
}
}
// Now try to decode
do {
return try tenupJsonDecoder.decode(T.self, from: data)
} catch {
print("Decoding error: \(error)")
throw error
}
return try tenupJsonDecoder.decode(T.self, from: task.0)
}
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse {
return try await FederalDataService.shared.federalClubs(country: country, city: city, radius: radius, location: location)
/*
{
"geocoding[country]": "fr",
@ -93,7 +71,7 @@ class NetworkFederalService {
//"geocoding%5Bcountry%5D=fr&geocoding%5Bville%5D=13%20Avenue%20Emile%20Bodin%2013260%20Cassis&geocoding%5Brayon%5D=15&geocoding%5BuserPosition%5D%5Blat%5D=43.22278594081477&geocoding%5BuserPosition%5D%5Blng%5D=5.556953900769194&geocoding%5BuserPosition%5D%5BshowDistance%5D=true&nombreResultat=0&diplomeEtatOption=false&galaxieOption=false&fauteuilOption=false&tennisSanteOption=false"
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
@ -115,11 +93,105 @@ class NetworkFederalService {
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> [FederalTournament] {
return try await FederalDataService.shared.getClubFederalTournaments(page: page, tournaments: tournaments, club: club, codeClub: codeClub, startDate: startDate, endDate: endDate).tournaments
}
if formId.isEmpty {
do {
try await getNewBuildForm()
} catch {
print("getClubFederalTournaments", error)
}
}
var dateComponent = ""
if let startDate, let endDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(endDate.endOfMonth.twoDigitsYearFormatted)"
} else if let startDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(Calendar.current.date(byAdding: .month, value: 3, to: startDate)!.endOfMonth.twoDigitsYearFormatted)"
}
let parameters = """
recherche_type=club&club[autocomplete][value_container][value_field]=\(codeClub.replaceCharactersFromSet(characterSet: .whitespaces))&club[autocomplete][value_container][label_field]=\(club.replaceCharactersFromSet(characterSet: .whitespaces, replacementString: "+"))&pratique=PADEL\(dateComponent)&page=\(page)&sort=dateDebut+asc&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page
"""
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) {
return try await FederalDataService.shared.getUmpireData(idTournament: idTournament)
request.httpMethod = "POST"
request.httpBody = postData
let commands : [HttpCommand] = try await runTenupTask(request: request)
if commands.anySatisfy({ $0.command == "alert" }) {
throw NetworkManagerError.maintenance
}
let resultCommand = commands.first(where: { $0.results != nil })
if let gatheredTournaments = resultCommand?.results?.items {
var finalTournaments = tournaments + gatheredTournaments
if let count = resultCommand?.results?.nb_results {
if finalTournaments.count < count {
let newTournaments = try await getClubFederalTournaments(page: page+1, tournaments: finalTournaments, club: club, codeClub: codeClub)
finalTournaments = finalTournaments + newTournaments
}
}
return finalTournaments
}
// do {
// } catch {
// print("getClubFederalTournaments", error)
// }
//
return []
}
func getNewBuildForm() async throws {
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/tournois")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.addValue("trailers", forHTTPHeaderField: "TE")
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
if let stringData = String(data: task.0, encoding: .utf8) {
let stringDataFolded = stringData.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines)
let prefix = "form_build_id\"value=\"form-"
var finalData = ""
if let lab = stringDataFolded.matches(of: try! Regex("\(prefix)")).last {
finalData = String(stringDataFolded[lab.range.upperBound...])
}
let suffix = "\"/><inputtype=\"hidden\"name=\"form_id\"value=\"recherche_tournois_form"
if let suff = finalData.firstMatch(of: try! Regex("\(suffix)")) {
finalData = String(finalData[..<suff.range.lowerBound])
}
print(finalData)
formId = "form-\(finalData)"
} else {
print("no data found in html")
}
}
}

@ -6,7 +6,6 @@
//
import Foundation
import PadelClubData
class NetworkManager {
static let shared: NetworkManager = NetworkManager()
@ -52,9 +51,9 @@ class NetworkManager {
let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
let fileURL = URLs.main.extend(path: "static/rankings/\(dateString)")
let fileURL = URL(string: "https://xlr.alwaysdata.net/static/rankings/\(dateString)")
var request = URLRequest(url:fileURL)
var request = URLRequest(url:fileURL!)
request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition")
if FileManager.default.fileExists(atPath: destinationFileUrl.path()), let modificationDate = destinationFileUrl.creationDate() {
request.addValue(formatDateForHTTPHeader(modificationDate), forHTTPHeaderField: "If-Modified-Since")

@ -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
}
}
}

@ -6,7 +6,6 @@
//
import Foundation
import PadelClubData
typealias LineIterator = AsyncLineSequence<URL.AsyncBytes>.AsyncIterator
struct Line: Identifiable {
@ -71,18 +70,18 @@ struct Line: Identifiable {
struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Line
let url: URL
private let url: URL
private var lineIterator: LineIterator
private let separator: Character
private let seperator: Character
private let quoteCharacter: Character = "\""
private var lineNumber = 0
private let date: Date
let maleData: Bool
init(url: URL, separator: Character = ";") {
init(url: URL, seperator: Character = ";") {
self.date = url.dateFromPath
self.url = url
self.separator = separator
self.seperator = seperator
self.lineIterator = url.lines.makeAsyncIterator()
self.maleData = url.path().contains(SourceFile.messieurs.rawValue)
}
@ -128,7 +127,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
func makeAsyncIterator() -> CSVParser {
return self
}
private func split(line: String) -> [String?] {
var data = [String?]()
var inQuote = false
@ -140,7 +139,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
inQuote = !inQuote
continue
case separator:
case seperator:
if !inQuote {
data.append(currentString.isEmpty ? nil : currentString)
currentString = ""
@ -158,63 +157,4 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
return data
}
/// Splits the CSV file into multiple temporary CSV files, each containing `size` lines.
/// Returns an array of new `CSVParser` instances pointing to these chunked files.
func getChunkedParser(size: Int) async throws -> [CSVParser] {
var chunkedParsers: [CSVParser] = []
var currentChunk: [String] = []
var iterator = self.makeAsyncIterator()
var chunkIndex = 0
while let line = try await iterator.next()?.rawValue {
currentChunk.append(line)
// When the chunk reaches the desired size, write it to a file
if currentChunk.count == size {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
chunkIndex += 1
currentChunk.removeAll()
}
}
// Handle remaining lines (if any)
if !currentChunk.isEmpty {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
}
return chunkedParsers
}
/// Writes a chunk of CSV lines to a temporary file and returns its URL.
private func writeChunkToFile(chunk: [String], index: Int) throws -> URL {
let tempDirectory = FileManager.default.temporaryDirectory
let chunkURL = tempDirectory.appendingPathComponent("\(url.lastPathComponent)-\(index).csv")
let chunkData = chunk.joined(separator: "\n")
try chunkData.write(to: chunkURL, atomically: true, encoding: .utf8)
return chunkURL
}
}
/// Process all large CSV files concurrently and gather all mini CSVs.
func chunkAllSources(sources: [CSVParser], size: Int) async throws -> [CSVParser] {
var allChunks: [CSVParser] = []
await withTaskGroup(of: [CSVParser].self) { group in
for source in sources {
group.addTask {
return (try? await source.getChunkedParser(size: size)) ?? []
}
}
for await miniCSVs in group {
allChunks.append(contentsOf: miniCSVs)
}
}
return allChunks
}

@ -7,7 +7,6 @@
import Foundation
import TipKit
import PadelClubData
struct PadelBeachExportTip: Tip {
var title: Text {
@ -22,7 +21,7 @@ struct PadelBeachExportTip: Tip {
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
var actions: [Action] {
Action(id: "more-info-export", title: "En savoir plus")
Action(id: "beach-padel", title: "beach-padel.app.fft.fr")
@ -43,7 +42,7 @@ struct PadelBeachImportTip: Tip {
var image: Image? {
Image(systemName: "square.and.arrow.down")
}
var actions: [Action] {
Action(id: "more-info-import", title: "Importer le fichier excel beach-padel")
}
@ -62,8 +61,8 @@ struct GenerateLoserBracketTip: Tip {
var image: Image? {
nil
}
var actions: [Action] {
Action(id: "generate-loser-bracket", title: "Générer les matchs de classements")
}
@ -84,7 +83,7 @@ struct TeamChampionshipTip: Tip {
var image: Image? {
Image(systemName: "person.3")
}
var actions: [Action] {
Action(id: "list-manager", title: "Ouvrir le gestionnaire d'équipe")
}
@ -105,7 +104,7 @@ struct TeamChampionshipMainScreenTip: Tip {
var image: Image? {
Image(systemName: "arrow.uturn.backward")
}
var actions: [Action] {
Action(id: "set-list-manager-main", title: "Afficher sur l'écran principal")
}
@ -194,7 +193,7 @@ struct InscriptionManagerWomanRankTip: Tip {
var image: Image? {
Image(systemName: "figure.dress.line.vertical.figure")
}
var title: Text {
Text("Rang d'une joueuse dans un tournoi messieurs")
}
@ -214,7 +213,7 @@ struct InscriptionManagerRankUpdateTip: Tip {
var message: Text? {
Text("Padel Club vous permet de mettre à jour le classement des équipes inscrites. Si vous avez clôturé les inscriptions, la mise à jour du classement ne modifie pas la phase d'intégration de l'équipe, poule ou tableau final. Vous pouvez manuellement mettre à jour cette option.")
}
var image: Image? {
Image(systemName: "list.number")
}
@ -233,7 +232,7 @@ struct SharePictureTip: Tip {
var message: Text? {
Text("Lors d'un partage d'une photo, le texte est disponible dans le presse-papier du téléphone")
}
var image: Image? {
Image(systemName: "photo.badge.checkmark.fill")
}
@ -247,7 +246,7 @@ struct NewRankDataAvailableTip: Tip {
var message: Text? {
Text("Padel Club récupère toutes les données publique provenant de la FFT. L'importation de ce nouveau classement peut prendre plusieurs dizaines de secondes.")
}
var image: Image? {
Image(systemName: "exclamationmark.icloud")
}
@ -267,7 +266,7 @@ struct ClubSearchTip: Tip {
var message: Text? {
Text("Padel Club peut rechercher un club autour de vous, d'une ville ou d'un code postal, facilitant ainsi la saisie d'information.")
}
var image: Image? {
Image(systemName: "house.and.flag.fill")
}
@ -276,7 +275,7 @@ struct ClubSearchTip: Tip {
Action(id: ActionKey.searchAroundMe.rawValue, title: "Chercher autour de moi")
Action(id: ActionKey.searchCity.rawValue, title: "Chercher une ville")
}
enum ActionKey: String {
case searchAroundMe = "search-around-me"
case searchCity = "search-city"
@ -292,7 +291,7 @@ struct SlideToDeleteTip: Tip {
var message: Text? {
Text("Vous pouvez effacer un club en glissant votre doigt vers la gauche")
}
var image: Image? {
Image(systemName: "trash")
}
@ -307,7 +306,7 @@ struct MultiTournamentsEventTip: Tip {
var message: Text? {
Text("Padel Club permet de gérer plusieurs tournois ayant lieu en même temps. Un P100 homme et dame le même week-end par exemple.")
}
var image: Image? {
Image(systemName: "trophy.circle")
}
@ -321,7 +320,7 @@ struct NotFoundAreWalkOutTip: Tip {
var message: Text? {
Text("Si une équipe déjà présente dans votre liste d'attente n'est pas dans le fichier, elle sera mise WO")
}
var image: Image? {
Image(systemName: "person.2.slash.fill")
}
@ -339,7 +338,7 @@ struct TournamentPublishingTip: Tip {
var message: Text? {
Text("Padel Club vous permet de publier votre tournoi et rendre accessible à tous les résultats des matchs et l'évolution de l'événement. Les informations seront accessibles sur le site Padel Club.")
}
var image: Image? {
Image("PadelClub_logo_fondclair_transparent")
}
@ -353,7 +352,7 @@ struct TournamentTVBroadcastTip: Tip {
var message: Text? {
return Text("Padel Club vous propose un site spéficique à utiliser sur les écrans de votre club, présentant de manière intelligente l'évolution de votre tournoi.")
}
var image: Image? {
Image(systemName: "sparkles.tv")
}
@ -362,7 +361,7 @@ struct TournamentTVBroadcastTip: Tip {
struct TournamentSelectionTip: Tip {
@Parameter
static var tournamentCount: Int? = nil
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -380,7 +379,7 @@ struct TournamentSelectionTip: Tip {
var message: Text? {
return Text("Vous pouvez appuyer sur la barre de navigation pour accéder à un tournoi de votre événement.")
}
var image: Image? {
Image(systemName: "filemenu.and.selection")
}
@ -389,7 +388,7 @@ struct TournamentSelectionTip: Tip {
struct TournamentRunningTip: Tip {
@Parameter
static var isRunning: Bool = false
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -407,7 +406,7 @@ struct TournamentRunningTip: Tip {
var message: Text? {
return Text("Le tournoi a commencé, les options utiles surtout à sa préparation sont maintenant accessibles dans le menu en haut à droite.")
}
var image: Image? {
Image(systemName: "ellipsis.circle")
}
@ -422,18 +421,18 @@ struct CreateAccountTip: Tip {
let message = "Un compte est nécessaire pour publier le tournoi sur [Padel Club](\(URLs.main.rawValue)) et profiter de toutes les pages du site, comme le mode TV pour transformer l'expérience de vos tournois !"
return Text(.init(message))
}
var image: Image? {
Image(systemName: "person.crop.circle")
}
var actions: [Action] {
Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte")
//todo
//Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus")
Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Voir le site Padel Club")
Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Jeter un oeil au site Padel Club")
}
enum ActionKey: String {
case createAccount = "createAccount"
case learnMore = "learnMore"
@ -444,7 +443,7 @@ struct CreateAccountTip: Tip {
struct SlideToDeleteSeedTip: Tip {
@Parameter
static var seeds: Int = 0
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -462,7 +461,7 @@ struct SlideToDeleteSeedTip: Tip {
var message: Text? {
Text("Vous pouvez retirer une tête de série de sa position en glissant votre doigt vers la gauche")
}
var image: Image? {
Image(systemName: "person.fill.xmark")
}
@ -471,7 +470,7 @@ struct SlideToDeleteSeedTip: Tip {
struct PrintTip: Tip {
@Parameter
static var seeds: Int = 0
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -481,7 +480,7 @@ struct PrintTip: Tip {
}
]
}
var title: Text {
Text("Coup d'oeil de votre tableau")
}
@ -489,7 +488,7 @@ struct PrintTip: Tip {
var message: Text? {
Text("Vous pouvez avoir un aperçu de votre tableau ou l'imprimer.")
}
var image: Image? {
Image(systemName: "printer")
}
@ -506,9 +505,9 @@ struct PrintTip: Tip {
struct BracketEditTip: Tip {
@Parameter
static var matchesHidden: Int = 0
var nextRoundName: String?
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -529,14 +528,14 @@ struct BracketEditTip: Tip {
let wording = nextRoundName != nil ? "en \(nextRoundName!)" : "dans la manche suivante"
return Text("Padel Club a bien pris en compte \(article) tête\(Self.matchesHidden.pluralSuffix) de série positionnée\(Self.matchesHidden.pluralSuffix) \(wording). Le\(Self.matchesHidden.pluralSuffix) \(Self.matchesHidden) match\(Self.matchesHidden.pluralSuffix) inutile\(Self.matchesHidden.pluralSuffix) \(grammar) été désactivé automatiquement.")
}
var image: Image? {
Image(systemName: "rectangle.slash")
}
}
struct TeamsExportTip: Tip {
var title: Text {
Text("Exporter les paires")
}
@ -544,143 +543,18 @@ struct TeamsExportTip: Tip {
var message: Text? {
Text("Partager les paires comme indiqué dans le guide de la compétition à J-6 avant midi.")
}
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
}
struct TimeSlotMoveTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("Vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? {
Image(systemName: "arrow.up.arrow.down.circle")
}
}
struct TimeSlotMoveOptionTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("En cliquant ici, vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? {
Image(systemName: "sparkles")
}
}
struct PlayerTournamentSearchTip: Tip {
var title: Text {
Text("Cherchez un tournoi autour de vous !")
}
var message: Text? {
Text("Padel Club facilite la recherche de tournois et l'inscription !")
}
var image: Image? {
Image(systemName: "trophy.circle")
}
var actions: [Action] {
Action(id: ActionKey.selectAction.rawValue, title: "Éssayer")
}
enum ActionKey: String {
case selectAction = "selectAction"
}
}
struct OnlineRegistrationTip: Tip {
var title: Text {
Text("Inscription en ligne")
}
var message: Text? {
Text("Facilitez les inscriptions à votre tournoi en activant l'inscription en ligne. Les joueurs pourront s'inscrire directement depuis l'application ou le site Padel Club.")
}
var image: Image? {
Image(systemName: "person.2.crop.square.stack")
}
var actions: [Action] {
[
Action(id: ActionKey.more.rawValue, title: "En savoir plus"),
Action(id: ActionKey.enableOnlineRegistration.rawValue, title: "Activer dans les réglages du tournoi")
]
}
enum ActionKey: String {
case more = "more"
case enableOnlineRegistration = "enableOnlineRegistration"
}
}
struct ShouldTournamentBeOverTip: Tip {
var title: Text {
Text("Clôturer le tournoi ?")
}
var message: Text? {
Text("Le dernier match est terminé depuis plus de 2 heures. Si le tournoi a été annulé pour cause de météo vous pouvez l'indiquer comme 'Annulé' dans le menu en haut à droite, si ce n'est pas le cas, saisissez les scores manquants pour clôturer automatiquement le tournoi et publier le classement final.")
}
var image: Image? {
Image(systemName: "clock.badge.questionmark")
}
var actions: [Action] {
Action(id: "tournament-status", title: "Gérer le statut du tournoi")
}
}
struct UpdatePlannedDatesTip: Tip {
var title: Text {
Text("Mettre à jour la programmation des matchs")
}
var message: Text? {
Text("Tous les matchs dans le futur verront leur dates plannifiées mis à jour dans la section Programmation du site Padel Club.")
}
var image: Image? {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90.circle")
}
}
struct MergeTournamentTip: Tip {
var title: Text {
Text("Transfert de tournois")
}
var message: Text? {
Text("Vous pouvez transferer des tournois d'un autre événement dans celui-ci.")
}
var image: Image? {
Image(systemName: "square.and.arrow.down")
Image(systemName: "square.and.arrow.up")
}
}
struct TipStyleModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
var tint: Color?
var background: Color?
var asSection: Bool
func body(content: Content) -> some View {
if asSection {
Section {
@ -690,7 +564,7 @@ struct TipStyleModifier: ViewModifier {
preparedContent(content: content)
}
}
@ViewBuilder
func preparedContent(content: Content) -> some View {
if let background {

@ -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
}
}

@ -7,8 +7,6 @@
import Foundation
import SwiftUI
import TipKit
import PadelClubData
enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var id: Int { self.rawValue }
@ -20,27 +18,20 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
case activity
case history
case tenup
case around
enum ViewStyle {
case list
case calendar
}
var localizedTitleKey: String {
switch self {
case .activity:
return "À venir"
return "En cours"
case .history:
return "Terminé"
case .tenup:
return "Tenup"
case .around:
return "Autour"
}
}
func associatedTip() -> (any Tip)? {
switch self {
case .around:
return nil //PlayerTournamentSearchTip()
default:
return nil
}
}
@ -48,26 +39,25 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
localizedTitleKey
}
func systemImage() -> String? {
var systemImage: String {
switch self {
case .around:
return "location.magnifyingglass"
default:
return nil
case .activity:
return "squares.leading.rectangle"
case .history:
return "book.closed"
case .tenup:
return "tennisball"
}
}
func badgeValue() -> Int? {
switch self {
case .activity:
DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count
DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
case .history:
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
case .tenup:
FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+)
case .around:
nil
}
}
@ -94,25 +84,6 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
} else {
return nil
}
case .around:
return nil
}
}
}
enum ViewStyle {
case list
case calendar
}
struct ViewStyleKey: EnvironmentKey {
static let defaultValue: ViewStyle = .list
}
extension EnvironmentValues {
var viewStyle: ViewStyle {
get { self[ViewStyleKey.self] }
set { self[ViewStyleKey.self] = newValue }
}
}

@ -7,54 +7,32 @@
import SwiftUI
import LeStorage
import PadelClubData
@Observable
class FederalDataViewModel {
static let shared = FederalDataViewModel()
var federalTournaments: [FederalTournament] = []
var searchedFederalTournaments: [FederalTournament] = []
var levels: Set<TournamentLevel> = Set()
var categories: Set<TournamentCategory> = Set()
var ageCategories: Set<FederalTournamentAge> = Set()
var selectedClubs: Set<String> = Set()
var id: UUID = UUID()
var searchAttemptCount: Int = 0
var dayDuration: Int?
var dayPeriod: DayPeriod = .all
var weekdays: Set<Int> = Set()
var lastError: NetworkManagerError?
func filterStatus() -> String {
var labels: [String] = []
labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList())
labels.append(contentsOf: categories.map { $0.localizedCategoryLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedFederalAgeLabel() }.formatList())
labels.append(contentsOf: levels.map { $0.localizedLabel() })
labels.append(contentsOf: categories.map { $0.localizedLabel() })
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() })
let clubNames = selectedClubs.compactMap { codeClub in
let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub })
return club?.clubTitle(.short)
}
labels.append(contentsOf: clubNames.formatList())
labels.append(contentsOf: weekdays.map { Date.weekdays[$0 - 1] }.formatList())
if dayPeriod != .all {
labels.append(dayPeriod.localizedDayPeriodLabel())
}
if let dayDuration {
labels.append("max " + dayDuration.formatted() + " jour" + dayDuration.pluralSuffix)
}
labels.append(contentsOf: clubNames)
return labels.joined(separator: ", ")
}
var searchedClubs: [FederalClub] {
searchedFederalTournaments.compactMap { ft in
ft.federalClub
}.uniqued { fc in
fc.federalClubCode
}.sorted(by: \.federalClubName)
}
func selectedClub() -> Club? {
if selectedClubs.isEmpty == false {
return DataStore.shared.clubs.first(where: { $0.code == selectedClubs.first! })
@ -68,71 +46,25 @@ class FederalDataViewModel {
categories.removeAll()
ageCategories.removeAll()
selectedClubs.removeAll()
dayPeriod = .all
dayDuration = nil
weekdays.removeAll()
id = UUID()
}
func areFiltersEnabled() -> Bool {
(weekdays.isEmpty && levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false
}
var filteredFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: federalTournaments)
(levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty) == false
}
var filteredSearchedFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: searchedFederalTournaments)
}
func filteredFederalTournaments(from tournaments: [any FederalTournamentHolder]) -> [FederalTournamentHolder] {
tournaments.filter({ tournament in
var filteredFederalTournaments: [FederalTournament] {
federalTournaments.filter({ tournament in
(levels.isEmpty || tournament.tournaments.anySatisfy({ levels.contains($0.level) }))
&&
(categories.isEmpty || tournament.tournaments.anySatisfy({ categories.contains($0.category) }))
&&
(ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) }))
&&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
})
}
func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int {
tournaments.filter({ tournament in
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
})
.flatMap { $0.tournaments }
.filter {
(levels.isEmpty || levels.contains($0.level))
&&
(categories.isEmpty || categories.contains($0.category))
&&
(ageCategories.isEmpty || ageCategories.contains($0.age))
}
.count
}
func buildIsValid(_ build: any TournamentBuildHolder) -> Bool {
(levels.isEmpty || levels.contains(build.level))
&&
(categories.isEmpty || categories.contains(build.category))
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
}
func isTournamentValidForFilters(_ tournament: Tournament) -> Bool {
if tournament.isDeleted { return false }
let firstPart = (levels.isEmpty || levels.contains(tournament.level))
@ -140,13 +72,7 @@ class FederalDataViewModel {
(categories.isEmpty || categories.contains(tournament.category))
&&
(ageCategories.isEmpty || ageCategories.contains(tournament.age))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
if let codeClub = tournament.club()?.code {
return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub))
} else {
@ -161,59 +87,19 @@ class FederalDataViewModel {
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
&&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
}
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
// Use actor or lock to safely collect results from concurrent operations
actor TournamentCollector {
private var federalTournaments: [FederalTournament] = []
func add(tournaments: [FederalTournament]) {
self.federalTournaments.append(contentsOf: tournaments)
}
func getAllTournaments() -> [FederalTournament] {
return federalTournaments
}
}
let collector = TournamentCollector()
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
try await clubs.filter { $0.code != nil }.concurrentForEach { club in
let newTournaments = try await FederalDataService.shared.getClubFederalTournaments(
page: 0,
tournaments: [],
club: club.name,
codeClub: club.code!,
startDate: startDate,
endDate: endDate
)
let newTournaments = try await NetworkFederalService.shared.getClubFederalTournaments(page: 0, tournaments: [], club: club.name, codeClub: club.code!, startDate: startDate, endDate: endDate)
// Safely add to collector
await collector.add(tournaments: newTournaments.tournaments)
}
// Get all collected tournaments
let allNewTournaments = await collector.getAllTournaments()
// Now safely update the main array with unique items
for tournament in allNewTournaments {
if !self.federalTournaments.contains(where: { $0.id == tournament.id }) {
self.federalTournaments.append(tournament)
newTournaments.forEach { tournament in
if self.federalTournaments.contains(where: { $0.id == tournament.id }) == false {
self.federalTournaments.append(tournament)
}
}
}
}
}
struct FederalClub: Identifiable {
var id: String { federalClubCode }
var federalClubCode: String
var federalClubName: String
}

@ -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)
}
}

@ -6,13 +6,12 @@
//
import SwiftUI
import PadelClubData
@Observable
class NavigationViewModel {
var path = NavigationPath()
var toolboxPath = NavigationPath()
var accountPath: [MyAccountView.AccountScreen] = []
var umpirePath: [UmpireView.UmpireScreen] = []
var ongoingPath = NavigationPath()
var selectedTab: TabDestination?
var agendaDestination: AgendaDestination? = .activity

@ -6,7 +6,6 @@
//
import SwiftUI
import PadelClubData
class DebouncableViewModel: ObservableObject {
@Published var debouncableText: String = ""
@ -15,13 +14,13 @@ class DebouncableViewModel: ObservableObject {
class SearchViewModel: ObservableObject, Identifiable {
let id: UUID = UUID()
var allowSelection: Int = 0
var allowSelection : Int = 0
var codeClub: String? = nil
var clubName: String? = nil
var ligueName: String? = nil
var showFemaleInMaleAssimilation: Bool = false
var hidePlayers: [String]?
@Published var debouncableText: String = ""
@Published var searchText: String = ""
@Published var task: DispatchWorkItem?
@ -36,115 +35,66 @@ class SearchViewModel: ObservableObject, Identifiable {
@Published var selectedPlayers: Set<ImportedPlayer> = Set()
@Published var filterSelectionEnabled: Bool = false
@Published var isPresented: Bool = false
@Published var selectedAgeCategory: FederalTournamentAge = .unlisted
@Published var mostRecentDate: Date? = nil
var mostRecentDate: Date? = nil
var selectionIsOver: Bool {
if allowSingleSelection && selectedPlayers.count == 1 {
return true
} else if allowMultipleSelection && selectedPlayers.count == allowSelection {
return true
}
return false
}
var allowMultipleSelection: Bool {
allowSelection > 1 || allowSelection == -1
}
var allowSingleSelection: Bool {
allowSelection == 1
}
var debounceTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.4 : 0.1
}
var throttleTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.15 : 0.1
}
var contentUnavailableMessage: String {
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."]
if tokens.isEmpty {
message.append(
"Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver."
)
message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois. Dans ce pas, Padel Club ne pourra pas le trouver.")
}
return message.joined(separator: "\n")
}
func sortTitle() -> String {
var base = [sortOption.localizedLabel()]
base.append((ascending ? "croissant" : "décroissant"))
if selectedAgeCategory != .unlisted {
base.append(selectedAgeCategory.localizedFederalAgeLabel())
}
return base.joined(separator: " ")
}
func codeClubs() -> [String] {
let clubs: [Club] = DataStore.shared.user.clubsObjects()
return clubs.compactMap { $0.code }
}
func getCodeClub() -> String? {
if let codeClub { return codeClub }
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode {
return userCodeClub
}
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode { return userCodeClub }
return nil
}
func getLigueName() -> String? {
if let ligueName { return ligueName }
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName {
return userLigueName
}
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName { return userLigueName }
return nil
}
func shouldIncludeSearchTextPredicate() -> Bool {
if allowMultipleSelection {
return true
}
if allowSingleSelection {
return true
}
if tokens.isEmpty == false || hideAssimilation || selectedAgeCategory != .unlisted {
return true
}
return dataSet == .national && searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
}
func showIndex() -> Bool {
if dataSet == .national {
if searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
{
return false
} else {
return isFiltering()
}
}
if dataSet == .ligue { return isFiltering() }
if filterOption == .all { return isFiltering() }
if (dataSet == .national || dataSet == .ligue) { return false }
if filterOption == .all { return false }
return true
}
func isFiltering() -> Bool {
searchText.isEmpty == false || tokens.isEmpty == false || hideAssimilation
|| selectedAgeCategory != .unlisted
}
func prompt(forDataSet: DataSet) -> String {
switch forDataSet {
case .national:
@ -161,7 +111,7 @@ class SearchViewModel: ObservableObject, Identifiable {
return "dans mes favoris"
}
}
func label(forDataSet: DataSet) -> String {
switch forDataSet {
case .national:
@ -178,240 +128,100 @@ class SearchViewModel: ObservableObject, Identifiable {
}
func words() -> [String] {
let cleanedText = searchText.cleanSearchText().canonicalVersionWithPunctuation.trimmed
return cleanedText.components(
separatedBy: .whitespaces)
return searchText.canonicalVersionWithPunctuation.trimmed.components(separatedBy: .whitespaces)
}
func wordsPredicates() -> NSPredicate? {
let words = words().filter({ $0.isEmpty == false })
// Handle special case of hyphenated words
let hyphenatedWords = searchText.components(separatedBy: .whitespaces)
.filter { $0.contains("-") }
var predicates: [NSPredicate] = []
// Add predicates for hyphenated words
for word in hyphenatedWords {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", word))
let parts = word.components(separatedBy: "-")
for part in parts where part.count > 1 {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
}
}
// Regular words processing
switch words.count {
case 2:
predicates.append(contentsOf: [
NSPredicate(
format:
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@",
words[0], words[1]),
NSPredicate(
format:
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@",
words[1], words[0]),
// For multi-word first names, try the two words as a first name
NSPredicate(
format: "canonicalFirstName CONTAINS[cd] %@", words.joined(separator: " ")),
])
case 3:
// Handle potential cases like "Jean Christophe CROS"
predicates.append(contentsOf: [
// First two words as first name, last as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0] + " " + words[1], words[2]),
// First as first name, last two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0], words[1] + " " + words[2]),
// Last as first name, first two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[2], words[0] + " " + words[1]),
])
let predicates = [
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[0], words[1]),
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[1], words[0]),
]
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
default:
if words.count > 0 {
// For single word or many words, try matching against full name
predicates.append(
NSPredicate(
format: "canonicalFullName CONTAINS[cd] %@",
words.joined(separator: " ")))
}
}
return predicates.isEmpty
? nil : NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func searchTextPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union(
CharacterSet(charactersIn: "-"))
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
// Check for license numbers
let matches = canonicalVersionWithoutPunctuation.licencesFound()
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: licensesPredicates))
if canonicalVersionWithoutPunctuation.isEmpty == false {
let wordsPredicates = wordsPredicates()
if let wordsPredicates {
predicates.append(wordsPredicates)
}
// Add match for full name
predicates.append(
NSPredicate(
format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
// Add pattern match for more flexible matching
let components = canonicalVersionWithoutPunctuation.split(separator: " ")
let pattern = components.joined(separator: ".*")
predicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
// // Look for exact matches on first or last name
// let words = canonicalVersionWithoutPunctuation.components(separatedBy: .whitespaces)
// for word in words where word.count > 2 {
// predicates.append(
// NSPredicate(
// format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", word, word)
// )
// }
}
if predicates.isEmpty {
return nil
}
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func orPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union(
CharacterSet(charactersIn: "-"))
var predicates : [NSPredicate] = []
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed
if tokens.isEmpty {
if shouldIncludeSearchTextPredicate(),
canonicalVersionWithoutPunctuation.isEmpty == false
{
if let searchTextPredicate = searchTextPredicate() {
predicates.append(searchTextPredicate)
}
}
}
// Process tokens
for token in tokens {
switch token {
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(
NSPredicate(
format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(
NSPredicate(
format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(
NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(
NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation
switch tokens.first {
case .none:
if canonicalVersionWithoutPunctuation.isEmpty == false {
let wordsPredicates = wordsPredicates()
if let wordsPredicates {
predicates.append(wordsPredicates)
} else {
predicates.append(
NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
case .age:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "birthYear == 0"))
} else if let birthYear = Int(canonicalVersionWithoutPunctuation) {
predicates.append(
NSPredicate(format: "birthYear == %@", birthYear.formattedAsRawString()))
predicates.append(NSPredicate(format: "license contains[cd] %@", canonicalVersionWithoutPunctuation))
}
predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation))
let components = canonicalVersionWithoutPunctuation.split(separator: " ").sorted()
let pattern = components.joined(separator: ".*")
let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
predicates.append(predicate)
}
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(NSPredicate(format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(NSPredicate(format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
}
if predicates.isEmpty {
return nil
}
let full = NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
return full
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func predicate() -> NSPredicate? {
var predicates: [NSPredicate?] = [
var predicates : [NSPredicate?] = [
orPredicate(),
filterOption == .male ? NSPredicate(format: "male == YES") : nil,
filterOption == .female ? NSPredicate(format: "male == NO") : nil,
filterOption == .male ?
NSPredicate(format: "male == YES") :
nil,
filterOption == .female ?
NSPredicate(format: "male == NO") :
nil,
]
if let mostRecentDate {
predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
//predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
}
if hideAssimilation {
predicates.append(NSPredicate(format: "assimilation == %@", "Non"))
}
if selectedAgeCategory != .unlisted {
let computedBirthYear = selectedAgeCategory.computedBirthYear()
if let left = computedBirthYear.0 {
predicates.append(
NSPredicate(format: "birthYear >= %@", left.formattedAsRawString()))
}
if let right = computedBirthYear.1 {
predicates.append(
NSPredicate(format: "birthYear <= %@", right.formattedAsRawString()))
}
}
switch dataSet {
case .national:
break
@ -437,203 +247,18 @@ class SearchViewModel: ObservableObject, Identifiable {
if hidePlayers?.isEmpty == false {
predicates.append(NSPredicate(format: "NOT (license IN %@)", hidePlayers!))
}
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 }))
}
func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] {
sortOption.sortDescriptors(ascending, dataSet: dataSet)
}
func nsSortDescriptors() -> [NSSortDescriptor] {
sortDescriptors().map { NSSortDescriptor($0) }
}
static func getSpecialSlashPredicate(inputString: String) -> NSPredicate? {
// Define a regular expression to find slashes between alphabetic characters (not digits)
print(inputString)
let cleanedInput = inputString.cleanSearchText()
let pattern = /(\b[A-Za-z]+)\s*\/\s*([A-Za-z]+\b)/
// Find matches in the input string
guard let match = cleanedInput.firstMatch(of: pattern) else {
print("No valid name pairs found")
return nil
}
let lastName1 = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines)
let lastName2 = match.output.2.trimmingCharacters(in: .whitespacesAndNewlines)
// Ensure both names are not empty
guard !lastName1.isEmpty && !lastName2.isEmpty else {
print("One or both names are empty")
return nil
}
// Create the NSPredicate for searching in the `lastName` field
let predicate = NSPredicate(
format: "lastName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", lastName1, lastName2)
// Output the result
//print("Generated Predicate: \(predicate)")
return predicate
}
static func pastePredicate(
pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption
) -> NSPredicate? {
print("🔍 pastePredicate called with: \(pasteField)")
print("📅 mostRecentDate: \(String(describing: mostRecentDate))")
print("🔍 filterOption: \(filterOption)")
var andPredicates = [NSPredicate]()
var orPredicates = [NSPredicate]()
// Check for license numbers
let matches = pasteField.licencesFound()
print("🎫 Licenses found: \(matches)")
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
orPredicates = licensesPredicates
if matches.count == 2 {
print("✅ Returning early with 2 license predicates")
return NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)
}
// Add gender filter if specified
if filterOption == .female {
print("👩 Adding female filter")
andPredicates.append(NSPredicate(format: "male == NO"))
} else if filterOption == .male {
print("👨 Adding male filter")
andPredicates.append(NSPredicate(format: "male == YES"))
}
// Add date filter if specified
if let mostRecentDate {
print("📆 Adding date filter for: \(mostRecentDate)")
andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
}
// Check for slashes (representing alternatives)
if let slashPredicate = getSpecialSlashPredicate(inputString: pasteField) {
print("🔀 Found slash predicate")
orPredicates.append(slashPredicate)
}
// Prepare text for processing - preserve hyphens but remove digits
var text =
pasteField
.replacingOccurrences(of: "[\\(\\)\\[\\]\\{\\}]", with: "", options: .regularExpression)
.replacingOccurrences(of: "/", with: " ") // Replace slashes with spaces
.replacingOccurrences(of: "[\\.,;:!?]", with: " ", options: .regularExpression) // Replace common punctuation with spaces
.trimmingCharacters(in: .whitespacesAndNewlines)
print("🧹 Cleaned text: \"\(text)\"")
// Remove digits
let digitPattern = /\b\w*\d\w*\b/
text = text.replacing(digitPattern, with: "").trimmingCharacters(
in: .whitespacesAndNewlines)
print("🔢 After digit removal: \"\(text)\"")
// Split text by whitespace to get potential name components
let textComponents = text.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter {
!$0.isEmpty && $0.count > 1 && !["de", "la", "le", "du", "et", "si"].contains($0.lowercased())
}
print("📚 Text components: \(textComponents)")
if textComponents.count < 50 {
print("✓ Text components count is reasonable: \(textComponents.count)")
// Handle exact fullname match
let fullName = textComponents.joined(separator: " ")
if !fullName.isEmpty {
print("📝 Adding predicate for full name: \"\(fullName)\"")
orPredicates.append(
NSPredicate(format: "canonicalFullName CONTAINS[cd] %@", fullName))
}
// Handle hyphenated last names
let hyphenatedComponents = textComponents.filter { $0.contains("-") }
print("🔗 Hyphenated components: \(hyphenatedComponents)")
for component in hyphenatedComponents {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", component))
print("➕ Added hyphenated last name predicate: \"\(component)\"")
// Also search for each part of the hyphenated name
let parts = component.components(separatedBy: "-")
for part in parts {
if part.count > 1 {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
print("➕ Added hyphenated part predicate: \"\(part)\"")
}
}
}
// Try different combinations for first/last name
if textComponents.count > 1 {
print("🔄 Creating combinations with \(textComponents.count) components")
// Try each pair of components as first+last and last+first
for i in 0..<textComponents.count-1 {
let j = i + 1
print("👥 Trying adjacent pair: \"\(textComponents[i])\" and \"\(textComponents[j])\"")
// orPredicates.append(
// NSPredicate(
// format:
// "(firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@) OR (firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@)",
// textComponents[i], textComponents[j], textComponents[j],
// textComponents[i]
// ))
// Also try beginswith for more precise matches
orPredicates.append(
NSPredicate(
format:
"(firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@) OR (firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@)",
textComponents[i], textComponents[j], textComponents[j],
textComponents[i]
))
}
} else if textComponents.count == 1 {
print("👤 Single component search: \"\(textComponents[0])\"")
// If only one component, search in both first and last name
orPredicates.append(
NSPredicate(
format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@",
textComponents[0], textComponents[0]))
}
// Add pattern match for canonical full name
// let pattern = textComponents.joined(separator: ".*")
// print("🔍 Adding pattern match: \"\(pattern)\"")
// orPredicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
} else {
print(" Too many text components: \(textComponents.count) - skipping name combinations")
}
// Construct final predicate
var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates)
print("📊 AND predicates count: \(andPredicates.count)")
if !orPredicates.isEmpty {
print("📊 OR predicates count: \(orPredicates.count)")
let orCompoundPredicate = NSCompoundPredicate(
orPredicateWithSubpredicates: orPredicates)
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate, orCompoundPredicate,
])
}
print("🏁 Final predicate created")
print(predicate)
return predicate
}
}
enum SearchToken: String, CaseIterable, Identifiable {
@ -642,34 +267,26 @@ enum SearchToken: String, CaseIterable, Identifiable {
case rankMoreThan = "rang >"
case rankLessThan = "rang <"
case rankBetween = "rang <>"
case age = "âge sportif"
var id: String {
rawValue
}
var message: String {
switch self {
case .club:
return
"Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
return "Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .ligue:
return
"Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
return "Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .rankMoreThan:
return
"Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
return "Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
case .rankLessThan:
return
"Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
return "Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
case .rankBetween:
return
"Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
case .age:
return "Taper une année de naissance"
return "Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
}
}
var titleLabel: String {
switch self {
case .club:
@ -680,11 +297,9 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Chercher un rang"
case .rankBetween:
return "Chercher une intervalle de classement"
case .age:
return "Chercher une année de naissance"
}
}
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .club:
@ -697,11 +312,9 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Rang inférieur ou égale à"
case .rankBetween:
return "Rang entre deux valeurs"
case .age:
return "Année de naissance"
}
}
var shortLocalizedLabel: String {
switch self {
case .club:
@ -714,11 +327,9 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Rang ≤"
case .rankBetween:
return "Rang ≥,≤"
case .age:
return "Né(e) en"
}
}
func icon() -> String {
switch self {
case .club:
@ -731,11 +342,9 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
var systemImage: String {
switch self {
case .club:
@ -748,8 +357,6 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
}
@ -760,9 +367,9 @@ enum DataSet: Int, Identifiable {
case club
case favoriteClubs
case favoritePlayers
static let allCases: [DataSet] = [.national, .ligue, .club, .favoriteClubs]
static let allCases : [DataSet] = [.national, .ligue, .club, .favoriteClubs]
var id: Int { rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
@ -776,130 +383,55 @@ enum DataSet: Int, Identifiable {
return "Favori"
}
}
var tokens: [SearchToken] {
var _tokens: [SearchToken] = []
switch self {
case .national:
_tokens = [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
return [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
case .ligue:
_tokens = [.club, .rankMoreThan, .rankLessThan, .rankBetween]
return [.club, .rankMoreThan, .rankLessThan, .rankBetween]
case .club:
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
return [.rankMoreThan, .rankLessThan, .rankBetween]
case .favoritePlayers, .favoriteClubs:
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
return [.rankMoreThan, .rankLessThan, .rankBetween]
}
_tokens.append(.age)
return _tokens
}
}
extension SortOption {
enum SortOption: Int, CaseIterable, Identifiable {
case name
case rank
case tournamentCount
case points
var id: Int { self.rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .name:
return "Nom"
case .rank:
return "Rang"
case .tournamentCount:
return "Tournoi"
case .points:
return "Points"
}
}
func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
switch self {
case .name:
return [
SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
]
return [SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation)]
case .rank:
if dataSet == .national || dataSet == .ligue {
return [
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
]
if (dataSet == .national || dataSet == .ligue) {
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)]
} else {
return [
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
}
case .tournamentCount:
return [
SortDescriptor(
\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
return [SortDescriptor(\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
case .points:
return [
SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
case .progression:
return [
SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
return [SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
}
}
}
//enum SortOption: Int, CaseIterable, Identifiable {
// case name
// case rank
// case tournamentCount
// case points
// case progression
//
// var id: Int { self.rawValue }
// func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
// switch self {
// case .name:
// return "Nom"
// case .rank:
// return "Rang"
// case .tournamentCount:
// return "Tournoi"
// case .points:
// return "Points"
// case .progression:
// return "Progression"
// }
// }
//
// func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
// switch self {
// case .name:
// return [
// SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// ]
// case .rank:
// if dataSet == .national || dataSet == .ligue {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
// ]
// } else {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// case .tournamentCount:
// return [
// SortDescriptor(
// \ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .points:
// return [
// SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .progression:
// return [
// SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// }
//}

@ -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
}
}
}

@ -17,7 +17,6 @@ enum TabDestination: CaseIterable, Identifiable {
case tournamentOrganizer
case umpire
case ongoing
case myAccount
var title: String {
switch self {
@ -31,8 +30,6 @@ enum TabDestination: CaseIterable, Identifiable {
return "Gestionnaire"
case .umpire:
return "Juge-Arbitre"
case .myAccount:
return "Compte"
}
}
@ -48,8 +45,6 @@ enum TabDestination: CaseIterable, Identifiable {
return "squares.below.rectangle"
case .umpire:
return "person.bust"
case .myAccount:
return "person.crop.circle"
}
}
}

@ -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()
//}

@ -7,7 +7,6 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallMessageCustomizationView: View {
@EnvironmentObject var dataStore: DataStore
@ -30,8 +29,8 @@ struct CallMessageCustomizationView: View {
init(tournament: Tournament) {
self.tournament = tournament
_customCallMessageBody = State(wrappedValue: DataStore.shared.user.summonsMessageBody ?? (DataStore.shared.user.summonsUseFullCustomMessage ? "" : ContactType.defaultCustomMessage))
_customCallMessageSignature = State(wrappedValue: DataStore.shared.user.getSummonsMessageSignature() ?? DataStore.shared.user.defaultSignature(tournament))
_customClubName = State(wrappedValue: tournament.customClubName ?? tournament.clubName ?? "Lieu du tournoi")
_customCallMessageSignature = State(wrappedValue: DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature())
_customClubName = State(wrappedValue: tournament.clubName ?? "Lieu du tournoi")
_summonsAvailablePaymentMethods = State(wrappedValue: DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods)
}
@ -44,20 +43,12 @@ struct CallMessageCustomizationView: View {
}
var computedMessage: String {
var linkMessage: String? {
if tournament.isPrivate == false, let shareLink = tournament.shareURL(.matches)?.absoluteString {
return "Vous pourrez suivre tous les résultats de ce tournoi sur le site :\n\n".appending(shareLink)
} else {
return nil
}
}
return [entryFeeMessage, customCallMessageBody, linkMessage].compacted().map { $0.trimmedMultiline }.joined(separator: "\n")
[entryFeeMessage, customCallMessageBody].compacted().map { $0.trimmed }.joined(separator: "\n")
}
var finalMessage: String? {
let localizedCalled = "convoqué" + (tournament.tournamentCategory == .women ? "e" : "") + "s"
return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.title, hideSenior: true)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)"
return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)"
}
var body: some View {
@ -90,7 +81,7 @@ struct CallMessageCustomizationView: View {
}
Divider()
FooterButtonView("défaut") {
customCallMessageSignature = DataStore.shared.user.defaultSignature(tournament)
customCallMessageSignature = DataStore.shared.user.defaultSignature()
_save()
}
Divider()
@ -104,16 +95,6 @@ struct CallMessageCustomizationView: View {
}
.headerProminence(.increased)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(focusedField != nil)
.toolbar(content: {
if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
}
})
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Message de convocation")
.toolbar {
@ -126,7 +107,7 @@ struct CallMessageCustomizationView: View {
} label: {
Text("Valider")
}
.buttonStyle(.borderedProminent)
.buttonStyle(.bordered)
}
}
}
@ -145,16 +126,8 @@ struct CallMessageCustomizationView: View {
}
private func _save() {
if customCallMessageBody.isEmpty {
self.dataStore.user.summonsMessageBody = nil
} else {
self.dataStore.user.summonsMessageBody = customCallMessageBody
}
if customCallMessageSignature.isEmpty {
self.dataStore.user.summonsMessageSignature = nil
} else {
self.dataStore.user.summonsMessageSignature = customCallMessageSignature
}
self.dataStore.user.summonsMessageBody = customCallMessageBody
self.dataStore.user.summonsMessageSignature = customCallMessageSignature
self.dataStore.user.summonsAvailablePaymentMethods = summonsAvailablePaymentMethods
self.dataStore.saveUser()
}
@ -235,13 +208,14 @@ struct CallMessageCustomizationView: View {
if let eventClub = tournament.eventObject()?.clubObject() {
let hasBeenCreated: Bool = eventClub.hasBeenCreated(by: StoreCenter.main.userId)
Section {
TextField("Nom du club", text: $customClubName)
TextField("Nom du club", text: $customClubName, axis: .vertical)
.lineLimit(2)
.autocorrectionDisabled()
.focused($focusedField, equals: .clubName)
.onSubmit {
tournament.customClubName = customClubName.prefixTrimmed(100)
eventClub.name = customClubName
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
try dataStore.clubs.addOrUpdate(instance: eventClub)
} catch {
Logger.error(error)
}
@ -275,7 +249,7 @@ struct CallMessageCustomizationView: View {
}
}.italic().foregroundStyle(.gray)
} header: {
Text("Exemple généré automatiquement")
Text("Rendu généré automatiquement")
}
}

@ -7,7 +7,6 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallSettingsView: View {
@EnvironmentObject var dataStore: DataStore
@ -16,15 +15,13 @@ struct CallSettingsView: View {
@State private var showSendToAllView: Bool = false
@State private var addLink: Bool = false
var tournamentStore: TournamentStore? {
var tournamentStore: TournamentStore {
return self.tournament.tournamentStore
}
var body: some View {
List {
Section {
NavigationLink {
CallMessageCustomizationView(tournament: tournament)
@ -67,7 +64,7 @@ struct CallSettingsView: View {
team.callDate = nil
}
do {
try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
@ -85,7 +82,7 @@ struct CallSettingsView: View {
}
}
do {
try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
@ -96,11 +93,11 @@ struct CallSettingsView: View {
//#endif
}
.sheet(isPresented: $showSendToAllView) {
SendToAllView(tournament: tournament, addLink: false)
SendToAllView(addLink: false)
.tint(.master)
}
.sheet(isPresented: $addLink) {
SendToAllView(tournament: tournament, addLink: true)
SendToAllView(addLink: true)
.tint(.master)
}
}

@ -7,7 +7,6 @@
import SwiftUI
import LeStorage
import PadelClubData
struct CallView: View {
@ -15,7 +14,6 @@ struct CallView: View {
let count: Int
let total: Int
let startDate: Date?
var title: String = "convoquées au bon horaire"
var body: some View {
VStack(spacing: 0) {
@ -34,7 +32,7 @@ struct CallView: View {
Text(startDate.formatted(.dateTime.weekday().day(.twoDigits).month().year()))
}
Spacer()
Text(title)
Text("convoquées au bon horaire")
}
.font(.caption)
.foregroundColor(.secondary)
@ -42,6 +40,14 @@ struct CallView: View {
}
}
struct TeamView: View {
let team: TeamRegistration
var body: some View {
TeamRowView(team: team, displayCallDate: true)
}
}
@EnvironmentObject var dataStore: DataStore
@EnvironmentObject var networkMonitor: NetworkMonitor
@ -51,7 +57,6 @@ struct CallView: View {
let callDate: Date
let matchFormat: MatchFormat
let roundLabel: String
let displayContext: SummoningDisplayContext
@State private var contactType: ContactType? = nil
@State private var sentError: ContactManagerError? = nil
@ -60,61 +65,9 @@ struct CallView: View {
@State var showUserCreationView: Bool = false
@State var summonParamByMessage: Bool = false
@State var summonParamReSummon: SummonType = .contact
let simpleMode : Bool
let summonType: SummonType
init(teams: [TeamRegistration], callDate: Date, matchFormat: MatchFormat, roundLabel: String) {
self.teams = teams
self.callDate = callDate
self.matchFormat = matchFormat
self.roundLabel = roundLabel
self.simpleMode = false
self.displayContext = .footer
self.summonType = .contact
}
init(teams: [TeamRegistration]) {
self.teams = teams
self.callDate = Date()
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = true
self.displayContext = .footer
self.summonType = .contact
}
init(team: TeamRegistration, displayContext: SummoningDisplayContext, summonType: SummonType) {
self.teams = [team]
let expectedSummonDate = team.expectedSummonDate()
self.displayContext = displayContext
self.summonType = summonType
if let expectedSummonDate, let initialMatch = team.initialMatch() {
self.callDate = expectedSummonDate
self.matchFormat = initialMatch.matchFormat
self.roundLabel = initialMatch.roundTitle() ?? "tableau"
self.simpleMode = false
} else if let expectedSummonDate, let initialGroupStage = team.groupStageObject() {
self.callDate = expectedSummonDate
self.matchFormat = initialGroupStage.matchFormat
self.roundLabel = "poule"
self.simpleMode = false
} else if let expectedSummonDate {
self.callDate = expectedSummonDate
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = false
} else {
self.callDate = Date()
self.matchFormat = MatchFormat.nineGames
self.roundLabel = ""
self.simpleMode = true
}
}
@State var summonParamReSummon: Bool = false
var tournamentStore: TournamentStore? {
var tournamentStore: TournamentStore {
return self.tournament.tournamentStore
}
@ -128,74 +81,49 @@ struct CallView: View {
}
}
private func _called(_ calledTeams: [TeamRegistration], _ success: Bool) {
if simpleMode {
return
}
private func _called(_ success: Bool) {
if success {
calledTeams.forEach { team in
self.teams.forEach { team in
team.callDate = callDate
if summonType.shouldConfirm() {
team.confirmationDate = nil
}
}
do {
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: calledTeams)
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
}
}
func finalMessage(summonType: SummonType, forcedEmptyMessage: Bool) -> String {
if summonType == .contactWithoutSignature {
return ""
}
if simpleMode || forcedEmptyMessage {
let signature = dataStore.user.getSummonsMessageSignature() ?? dataStore.user.defaultSignature(tournament)
return "\n\n\n\n" + signature
}
return ContactType.callingMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat, summonType: summonType)
func finalMessage(reSummon: Bool) -> String {
ContactType.callingMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat, reSummon: reSummon)
}
//
// var summonType: SummonType {
// if simpleMode {
// return .contact
// }
// return self.teams.allSatisfy({ $0.called() }) == true ? .summon : .summonWalkoutFollowUp
// }
var mainWord: String {
if simpleMode {
return "Contacter"
} else {
return "Convoquer"
}
var reSummon: Bool {
return self.teams.allSatisfy({ $0.called() })
}
var body: some View {
Group {
switch displayContext {
case .footer:
_footerStyleView()
case .menu:
_menuStyleView()
let callWord : String = (reSummon ? "Reconvoquer" : "Convoquer")
HStack {
if self.teams.count == 1 {
if let previousCallDate = teams.first?.callDate, Calendar.current.compare(previousCallDate, to: callDate, toGranularity: .minute) != .orderedSame {
Text("Reconvoquer \(self.callDate.localizedDate()) par")
} else {
Text("\(callWord) cette paire par")
}
} else {
Text("\(callWord) ces \(self.teams.count) paires par")
}
self._summonMenu(byMessage: true)
Text("ou")
self._summonMenu(byMessage: false)
}
.font(.subheadline)
.buttonStyle(.borderless)
.alert("Un problème est survenu", isPresented: messageSentFailed) {
Button("OK") {
}
if case .uncalledTeams(let uncalledTeams) = sentError {
NavigationLink("Voir les équipes non contactées") {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
}
}
} message: {
Text(_networkErrorMessage)
}
@ -210,22 +138,10 @@ struct CallView: View {
case .failed:
self.sentError = .messageFailed
case .sent:
let calledTeams = teams.filter { $0.getPhoneNumbers().isEmpty == false }
let uncalledTeams = teams.filter { $0.getPhoneNumbers().isEmpty }
if networkMonitor.connected == false {
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .messageNotSent
}
self.sentError = .messageNotSent
} else {
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else if uncalledTeams.isEmpty == false, calledTeams.isEmpty {
self._called(uncalledTeams, true)
}
self._called(calledTeams, true)
self._called(true)
}
@unknown default:
break
@ -240,23 +156,11 @@ struct CallView: View {
self.contactType = nil
self.sentError = .mailFailed
case .sent:
let calledTeams = teams.filter { $0.getMail().isEmpty == false }
let uncalledTeams = teams.filter { $0.getMail().isEmpty }
if networkMonitor.connected == false {
self.contactType = nil
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .mailNotSent
}
self.sentError = .mailNotSent
} else {
if uncalledTeams.isEmpty == false, calledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else if uncalledTeams.isEmpty == false, calledTeams.isEmpty {
self._called(uncalledTeams, true)
}
self._called(calledTeams, true)
self._called(true)
}
@unknown default:
break
@ -276,99 +180,25 @@ struct CallView: View {
NavigationStack {
LoginView(reason: LoginReason.loginRequiredForFeature) { _ in
self.showUserCreationView = false
self._summon(byMessage: self.summonParamByMessage,
summonType: self.summonType)
}
}
})
}
private func _footerStyleView() -> some View {
HStack {
let callWord : String = (summonType.isRecall() ? "Reconvoquer" : mainWord)
if self.teams.count == 1 {
if simpleMode {
Text("\(callWord) cette paire par")
} else {
if let previousCallDate = teams.first?.callDate, Calendar.current.compare(previousCallDate, to: callDate, toGranularity: .minute) != .orderedSame {
Text("Reconvoquer \(self.callDate.localizedDate()) par")
} else {
Text("\(callWord) cette paire par")
self._payTournamentAndExecute {
self._summon(byMessage: self.summonParamByMessage,
reSummon: self.summonParamByMessage)
}
}
} else {
Text("\(callWord) ces \(self.teams.count) paires par")
}
self._summonMenu(byMessage: true)
Text("ou")
self._summonMenu(byMessage: false)
}
.font(.subheadline)
.buttonStyle(.borderless)
}
private func _menuStyleView() -> some View {
Menu {
self._summonMenu(byMessage: true)
self._summonMenu(byMessage: false)
} label: {
VStack(alignment: .leading) {
let callWord : String = summonType.mainWord()
if self.teams.count == 1 {
Text("\(callWord) cette paire")
} else {
Text("\(callWord) ces \(self.teams.count) paires")
}
if let caption = summonType.caption() {
Text(caption).foregroundStyle(.secondary).font(.caption)
}
}
}
})
}
@ViewBuilder
private func _summonMenu(byMessage: Bool) -> some View {
if displayContext == .menu {
Button {
switch summonType {
case .contact:
self._summon(byMessage: byMessage, summonType: .contact, forcedEmptyMessage: true)
case .summon:
self._summon(byMessage: byMessage, summonType: .summon)
case .summonWalkoutFollowUp:
self._summon(byMessage: byMessage, summonType: .summonWalkoutFollowUp)
case .summonErrorFollowUp:
self._summon(byMessage: byMessage, summonType: .summonErrorFollowUp)
case .contactWithoutSignature:
self._summon(byMessage: byMessage, summonType: .contactWithoutSignature, forcedEmptyMessage: true)
}
} label: {
Text(byMessage ? "sms" : "mail")
.underline()
}
} else if summonType.isRecall() {
if self.reSummon {
Menu {
Button(mainWord) {
self._summon(byMessage: byMessage, summonType: simpleMode ? .contact : .summon)
Button("Convoquer") {
self._summon(byMessage: byMessage, reSummon: false)
}
Button("Re-convoquer suite à des forfaits") {
self._summon(byMessage: byMessage, summonType: .summonWalkoutFollowUp)
}
Button("Re-convoquer suite à une erreur") {
self._summon(byMessage: byMessage, summonType: .summonErrorFollowUp)
}
Divider()
Button("Contacter") {
self._summon(byMessage: byMessage, summonType: .contact, forcedEmptyMessage: true)
}
Button("Contacter sans texte par défaut") {
self._summon(byMessage: byMessage, summonType: .contactWithoutSignature, forcedEmptyMessage: true)
Button("Re-convoquer") {
self._summon(byMessage: byMessage, reSummon: true)
}
} label: {
@ -377,19 +207,21 @@ struct CallView: View {
}
} else {
FooterButtonView(byMessage ? "sms" : "mail") {
self._summon(byMessage: byMessage, summonType: summonType)
self._summon(byMessage: byMessage, reSummon: false)
}
}
}
private func _summon(byMessage: Bool, summonType: SummonType, forcedEmptyMessage: Bool = false) {
private func _summon(byMessage: Bool, reSummon: Bool) {
self.summonParamByMessage = byMessage
self.summonParamReSummon = summonType
self.summonParamReSummon = reSummon
self._verifyUser {
if byMessage {
self._contactByMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage)
} else {
self._contactByMail(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage)
self._payTournamentAndExecute {
if byMessage {
self._contactByMessage(reSummon: reSummon)
} else {
self._contactByMail(reSummon: reSummon)
}
}
}
}
@ -402,50 +234,47 @@ struct CallView: View {
}
}
// fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
// do {
// try self.tournament.payIfNecessary()
// handler()
// } catch {
// self.showSubscriptionView = true
// }
// }
fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
do {
try self.tournament.payIfNecessary()
handler()
} catch {
self.showSubscriptionView = true
}
}
fileprivate func _contactByMessage(summonType: SummonType, forcedEmptyMessage: Bool) {
self.contactType = .message(date: callDate,
fileprivate func _contactByMessage(reSummon: Bool) {
self.contactType = .message(date: callDate,
recipients: teams.flatMap { $0.getPhoneNumbers() },
body: finalMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage),
body: finalMessage(reSummon: reSummon),
tournamentBuild: nil)
}
fileprivate func _contactByMail(summonType: SummonType, forcedEmptyMessage: Bool) {
fileprivate func _contactByMail(reSummon: Bool) {
self.contactType = .mail(date: callDate,
recipients: tournament.umpireMail(),
bccRecipients: teams.flatMap { $0.getMail() },
body: finalMessage(summonType: summonType, forcedEmptyMessage: forcedEmptyMessage),
subject: tournament.mailSubject(),
body: finalMessage(reSummon: reSummon),
subject: tournament.tournamentTitle(),
tournamentBuild: nil)
}
private var _networkErrorMessage: String {
ContactManagerError.getNetworkErrorMessage(sentError: sentError, networkMonitorConnected: networkMonitor.connected)
}
}
struct TeamCallView: View {
@Environment(Tournament.self) var tournament: Tournament
let team: TeamRegistration
var action: (() -> Void)?
var body: some View {
NavigationLink {
CallMenuOptionsView(team: team, action: action)
.environment(tournament)
} label: {
TeamRowView(team: team, displayCallDate: true)
var errors: [String] = []
if networkMonitor.connected == false {
errors.append("L'appareil n'est pas connecté à internet.")
}
if sentError == .mailNotSent {
errors.append("Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer.")
}
.buttonStyle(.plain)
.listRowView(isActive: team.confirmed(), color: .green, hideColorVariation: true)
if (sentError == .messageFailed || sentError == .messageNotSent) {
errors.append("Le SMS n'a pas été envoyé")
}
if sentError == .mailFailed {
errors.append("Le mail n'a pas été envoyé")
}
return errors.joined(separator: "\n")
}
}

@ -7,13 +7,13 @@
import SwiftUI
import LeStorage
import PadelClubData
struct MenuWarningView: View {
let tournament: Tournament
let teams: [TeamRegistration]
var date: Date?
var message: String?
var umpireMail: String?
var subject: String?
@Binding var contactType: ContactType?
@ -24,7 +24,10 @@ struct MenuWarningView: View {
@State var savedContactType: ContactType? = nil
private func _getUmpireMail() -> [String]? {
return tournament.umpireMail()
if let umpireMail {
return [umpireMail]
}
return nil
}
var body: some View {
@ -43,11 +46,9 @@ struct MenuWarningView: View {
}
}
} label: {
Label("Prévenir", systemImage: "phone")
.labelStyle(.iconOnly)
Text("Prévenir")
.underline()
}
.menuStyle(.button)
.buttonStyle(.borderedProminent)
.sheet(isPresented: self.$showSubscriptionView, content: {
NavigationStack {
SubscriptionView(isPresented: self.$showSubscriptionView, showLackOfPlanMessage: true)
@ -71,13 +72,6 @@ struct MenuWarningView: View {
Label("Appeler", systemImage: "phone")
Text(number)
}
if let contactPhoneNumber = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "tel:\(contactPhoneNumber)") {
Link(destination: url) {
Label("Appeler", systemImage: "phone")
Text(contactPhoneNumber)
}
}
} else {
Menu {
ForEach(players) { player in
@ -87,12 +81,6 @@ struct MenuWarningView: View {
Text(number)
}
}
if let number = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "tel:\(number)") {
Link(destination: url) {
Label(player.playerLabel(.short), systemImage: "phone")
Text(number)
}
}
}
} label: {
Text("Appeler un joueur")
@ -108,13 +96,12 @@ struct MenuWarningView: View {
}
fileprivate func _contactByMessage(players: [PlayerRegistration], privateMode: Bool) {
self.savedContactType = .message(date: date, recipients: players.flatMap({ [$0.phoneNumber, $0.contactPhoneNumber].compacted() }), body: message, tournamentBuild: nil)
self.savedContactType = .message(date: date, recipients: players.compactMap({ $0.phoneNumber }), body: message, tournamentBuild: nil)
self._tryToContact()
}
fileprivate func _contactByMail(players: [PlayerRegistration], privateMode: Bool) {
let mails = players.flatMap({ [$0.email, $0.contactEmail].compacted() })
self.savedContactType = .mail(date: date, recipients: privateMode ? _getUmpireMail() : mails, bccRecipients: privateMode ? mails : nil, body: message, subject: subject, tournamentBuild: nil)
self.savedContactType = .mail(date: date, recipients: privateMode ? _getUmpireMail() : players.compactMap({ $0.email }), bccRecipients: privateMode ? players.compactMap({ $0.email }) : nil, body: message, subject: subject, tournamentBuild: nil)
self._tryToContact()
}
@ -137,7 +124,7 @@ struct MenuWarningView: View {
@ViewBuilder
func _teamActionView(_ team: TeamRegistration) -> some View {
Menu(team.teamNameLabel()) {
Menu("Toute l'équipe") {
let players = team.players()
_actionView(players: players)
}
@ -149,7 +136,9 @@ struct MenuWarningView: View {
fileprivate func _tryToContact() {
self._verifyUser {
self.contactType = self.savedContactType
self._payTournamentAndExecute {
self.contactType = self.savedContactType
}
}
}
@ -161,16 +150,14 @@ struct MenuWarningView: View {
}
}
// fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
// Task {
// do {
// try await tournament.payIfNecessary()
// handler()
// } catch {
// self.showSubscriptionView = true
// }
// }
// }
fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
do {
try tournament.payIfNecessary()
handler()
} catch {
self.showSubscriptionView = true
}
}
}

@ -6,7 +6,6 @@
//
import SwiftUI
import PadelClubData
struct PlayersWithoutContactView: View {
@Environment(Tournament.self) var tournament: Tournament
@ -14,7 +13,7 @@ struct PlayersWithoutContactView: View {
var body: some View {
Section {
let withoutEmails = players.filter({ $0.hasMail() == false })
let withoutEmails = players.filter({ $0.email?.isEmpty == true })
DisclosureGroup {
ForEach(withoutEmails) { player in
NavigationLink {
@ -32,7 +31,7 @@ struct PlayersWithoutContactView: View {
}
}
let withoutPhones = players.filter({ $0.hasMobilePhone() == false })
let withoutPhones = players.filter({ $0.phoneNumber?.isEmpty == true })
DisclosureGroup {
ForEach(withoutPhones) { player in
NavigationLink {
@ -46,7 +45,7 @@ struct PlayersWithoutContactView: View {
LabeledContent {
Text(withoutPhones.count.formatted())
} label: {
Text(Locale.current.region?.identifier == "FR" ? "Joueurs sans téléphone portable français" : "Joueurs sans téléphone")
Text("Joueurs sans téléphone")
}
}
} header: {
@ -54,4 +53,3 @@ struct PlayersWithoutContactView: View {
}
}
}

@ -6,53 +6,16 @@
//
import SwiftUI
import LeStorage
import PadelClubData
struct GroupStageCallingView: View {
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var dataStore: DataStore
@State private var displayByTeam: Bool = false
var body: some View {
let groupStages = tournament.groupStages()
List {
if tournament.isPrivate {
Section {
RowButtonView("Rendre visible sur Padel Club") {
tournament.isPrivate = false
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
} footer: {
Text("Si vous convoquez un tournoi privée, les joueurs n'auront pas le lien pour suivre le tournoi.")
.foregroundStyle(.logoRed)
}
}
let uncalled = groupStages.flatMap({ $0.unsortedTeams() }).filter({ $0.callDate == nil })
if uncalled.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalled)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalled.count.pluralSuffix) non contactée\(uncalled.count.pluralSuffix)", value: uncalled.count.formatted())
}
}
PlayersWithoutContactView(players: groupStages.flatMap({ $0.unsortedTeams() }).flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
if let startDate = tournament.groupStageStartDate() {
Section {
CallView(teams: tournament.groupStageTeams(), callDate: startDate, matchFormat: tournament.groupStageMatchFormat, roundLabel: "poule")
} header: {
Text("Convoquer toutes les équipes de poules à la même heure")
}
}
_sameTimeGroupStageView(groupStages: groupStages)
ForEach(groupStages) { groupStage in
@ -112,7 +75,7 @@ struct GroupStageCallingView: View {
ForEach(teams) { team in
if let startDate = groupStage.initialStartDate(forTeam: team) {
Section {
TeamCallView(team: team)
CallView.TeamView(team: team)
} header: {
Text(startDate.localizedDate())
} footer: {

@ -6,48 +6,18 @@
//
import SwiftUI
import LeStorage
import PadelClubData
struct SeedsCallingView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(Tournament.self) var tournament: Tournament
@State private var displayByMatch: Bool = true
var body: some View {
List {
if tournament.isPrivate {
Section {
RowButtonView("Rendre visible sur Padel Club") {
tournament.isPrivate = false
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
} footer: {
Text("Si vous convoquez un tournoi privée, les joueurs n'auront pas le lien pour suivre le tournoi.")
.foregroundStyle(.logoRed)
}
}
let tournamentRounds = tournament.rounds()
let uncalledSeeds = tournament.seededTeams().filter({ $0.callDate == nil })
if uncalledSeeds.isEmpty == false {
NavigationLink {
TeamsCallingView(teams: uncalledSeeds)
.environment(tournament)
} label: {
LabeledContent("Équipe\(uncalledSeeds.count.pluralSuffix) non contactée\(uncalledSeeds.count.pluralSuffix)", value: uncalledSeeds.count.formatted())
}
}
PlayersWithoutContactView(players: tournament.seededTeams().flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
ForEach(tournamentRounds) { round in
let seeds = round.teamsOrSeeds()
let seeds = round.seeds()
let callSeeds = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0) == false })
if seeds.isEmpty == false {
Section {
@ -85,16 +55,6 @@ struct SeedsCallingView: View {
}
}
let uncalledTeams = round.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())
}
}
if displayByMatch == false {
ForEach(keys, id: \.self) { time in
@ -120,7 +80,7 @@ struct SeedsCallingView: View {
let teams = round.seeds(inMatchIndex: match.index)
Section {
ForEach(teams) { team in
TeamCallView(team: team)
CallView.TeamView(team: team)
}
} header: {
HStack {

@ -7,47 +7,31 @@
import SwiftUI
import LeStorage
import PadelClubData
struct SendToAllView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var dataStore: DataStore
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var contactType: ContactType? = nil
@State private var contactMethod: Int = 1
@State private var contactRecipients: Set<String> = Set()
@State private var sentError: ContactManagerError? = nil
var event: Event?
var tournament: Tournament?
var addLink: Bool
let addLink: Bool
// @State var cannotPayForTournament: Bool = false
@State private var pageLink: PageLink = .matches
@State private var includeWaitingList: Bool = false
@State private var onlyWaitingList: Bool = false
@State var showSubscriptionView: Bool = false
@State var showUserCreationView: Bool = false
@State var summonParamByMessage: Bool = false
@State var summonParamReSummon: Bool = false
init(event: Event) {
self.event = event
self.addLink = false
_contactRecipients = .init(wrappedValue: Set(event.confirmedTournaments().map(\.id)))
}
init(tournament: Tournament, addLink: Bool) {
self.tournament = tournament
self.addLink = addLink
}
var tournamentStore: TournamentStore? {
return self.tournament?.tournamentStore
var tournamentStore: TournamentStore {
return self.tournament.tournamentStore
}
var messageSentFailed: Binding<Bool> {
@ -73,52 +57,35 @@ struct SendToAllView: View {
.labelsHidden()
.pickerStyle(.inline)
}
if let event {
LabeledContent {
Text(event.selectedTeams().filter({ contactRecipients.isEmpty || contactRecipients.contains($0.tournament) }).count.formatted())
} label: {
Text("Participants")
}
let confirmedTournaments = event.confirmedTournaments()
ForEach(confirmedTournaments) { tournament in
TournamentCellView(tournament: tournament).tag(tournament.id)
}
} else if let tournament {
Section {
ForEach(tournament.groupStages()) { groupStage in
let teams = groupStage.teams()
if teams.isEmpty == false {
LabeledContent {
Text(teams.count.formatted() + " équipe" + teams.count.pluralSuffix)
} label: {
Text(groupStage.groupStageTitle())
}
.tag(groupStage.id)
Section {
ForEach(tournament.groupStages()) { groupStage in
let teams = groupStage.teams()
if teams.isEmpty == false {
LabeledContent {
Text(teams.count.formatted() + " équipe" + teams.count.pluralSuffix)
} label: {
Text(groupStage.groupStageTitle())
}
.tag(groupStage.id)
}
ForEach(tournament.rounds()) { round in
let teams = round.teams()
if teams.isEmpty == false {
LabeledContent {
Text(teams.count.formatted() + " équipe" + teams.count.pluralSuffix)
} label: {
Text(round.roundTitle())
}
.tag(round.id)
}
ForEach(tournament.rounds()) { round in
let teams = round.teams()
if teams.isEmpty == false {
LabeledContent {
Text(teams.count.formatted() + " équipe" + teams.count.pluralSuffix)
} label: {
Text(round.roundTitle())
}
.tag(round.id)
}
Toggle("Inclure la liste d'attente", isOn: $includeWaitingList)
if includeWaitingList {
Toggle("Seulement la liste d'attente", isOn: $onlyWaitingList)
}
} footer: {
Text("Si vous ne souhaitez pas contacter toutes les équipes, choisissez un ou plusieurs groupes d'équipes manuellement.")
}
} footer: {
Text("Si vous ne souhaitez pas contacter toutes les équipes, choisissez un ou plusieurs groupes d'équipes manuellement.")
}
if addLink, event == nil {
if addLink {
Section {
let links : [PageLink] = [.teams, .summons, .groupStages, .matches, .rankings]
Picker(selection: $pageLink) {
@ -138,11 +105,6 @@ struct SendToAllView: View {
}
}
}
.onChange(of: includeWaitingList, {
if includeWaitingList == false {
onlyWaitingList = false
}
})
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
@ -159,13 +121,6 @@ struct SendToAllView: View {
.alert("Un problème est survenu", isPresented: messageSentFailed) {
Button("OK") {
}
if case .uncalledTeams(let uncalledTeams) = sentError, let tournament {
NavigationLink("Voir les équipes non contactées") {
TeamsCallingView(teams: uncalledTeams)
.environment(tournament)
}
}
} message: {
Text(_networkErrorMessage)
}
@ -180,21 +135,9 @@ struct SendToAllView: View {
case .failed:
self.sentError = .messageFailed
case .sent:
let uncalledTeams = _teams().filter { $0.getPhoneNumbers().isEmpty }
if networkMonitor.connected == false {
self.contactType = nil
if uncalledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .messageNotSent
}
} else {
if uncalledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
}
self.sentError = .messageNotSent
}
@unknown default:
break
}
@ -208,19 +151,9 @@ struct SendToAllView: View {
self.contactType = nil
self.sentError = .mailFailed
case .sent:
let uncalledTeams = _teams().filter { $0.getMail().isEmpty }
if networkMonitor.connected == false {
self.contactType = nil
if uncalledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
} else {
self.sentError = .mailNotSent
}
} else {
if uncalledTeams.isEmpty == false {
self.sentError = .uncalledTeams(uncalledTeams)
}
self.sentError = .mailNotSent
}
@unknown default:
break
@ -249,30 +182,19 @@ struct SendToAllView: View {
}
func _teams() -> [TeamRegistration] {
if let event {
return event.selectedTeams().filter({ contactRecipients.isEmpty || contactRecipients.contains($0.tournament) })
}
guard let tournament else { return [] }
let selectedSortedTeams = tournament.selectedSortedTeams()
if onlyWaitingList {
return tournament.waitingListSortedTeams(selectedSortedTeams: selectedSortedTeams)
}
if _roundTeams().isEmpty && _groupStagesTeams().isEmpty {
return tournament.selectedSortedTeams() + (includeWaitingList ? tournament.waitingListSortedTeams(selectedSortedTeams: selectedSortedTeams) : [])
return tournament.selectedSortedTeams()
}
return _roundTeams() + _groupStagesTeams() + (includeWaitingList ? tournament.waitingListSortedTeams(selectedSortedTeams: selectedSortedTeams) : [])
return _roundTeams() + _groupStagesTeams()
}
func _roundTeams() -> [TeamRegistration] {
guard let tournamentStore = self.tournamentStore else { return [] }
let rounds: [Round] = contactRecipients.compactMap { tournamentStore.rounds.findById($0) }
let rounds: [Round] = contactRecipients.compactMap { self.tournamentStore.rounds.findById($0) }
return rounds.flatMap { $0.teams() }
}
func _groupStagesTeams() -> [TeamRegistration] {
guard let tournamentStore = self.tournamentStore else { return [] }
let groupStages : [GroupStage] = contactRecipients.compactMap { tournamentStore.groupStages.findById($0) }
let groupStages : [GroupStage] = contactRecipients.compactMap { self.tournamentStore.groupStages.findById($0) }
return groupStages.flatMap { $0.teams() }
}
@ -288,13 +210,11 @@ struct SendToAllView: View {
func finalMessage() -> String {
var message = [String?]()
message.append("\n\n")
if let tournament, addLink, event == nil {
if addLink {
message.append(tournament.shareURL(pageLink)?.absoluteString)
} else if let event {
message.append(event.shareURL()?.absoluteString)
}
let signature = dataStore.user.getSummonsMessageSignature() ?? dataStore.user.defaultSignature(tournament)
let signature = dataStore.user.summonsMessageSignature ?? dataStore.user.defaultSignature()
message.append(signature)
@ -304,12 +224,13 @@ struct SendToAllView: View {
fileprivate func _contact() {
self._verifyUser {
if contactMethod == 0 {
contactType = .message(date: nil, recipients: _teams().flatMap { $0.unsortedPlayers() }.flatMap { [$0.phoneNumber, $0.contactPhoneNumber] }.compactMap({ $0 }), body: finalMessage(), tournamentBuild: nil)
} else {
let umpireMail = tournament?.umpireMail() ?? event?.umpireMail()
let subject = tournament?.mailSubject() ?? event?.mailSubject()
contactType = .mail(date: nil, recipients: umpireMail, bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.flatMap { [$0.email, $0.contactEmail] }.compactMap({ $0 }), body: finalMessage(), subject: subject, tournamentBuild: nil)
self._payTournamentAndExecute {
if contactMethod == 0 {
contactType = .message(date: nil, recipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.phoneNumber }, body: finalMessage(), tournamentBuild: nil)
} else {
contactType = .mail(date: nil, recipients: tournament.umpireMail(), bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.email }, body: finalMessage(), subject: tournament.tournamentTitle(), tournamentBuild: nil)
}
}
}
@ -323,18 +244,33 @@ struct SendToAllView: View {
}
}
// fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
// do {
// try tournament.payIfNecessary()
// handler()
// } catch {
// self.showSubscriptionView = true
// }
// }
fileprivate func _payTournamentAndExecute(_ handler: () -> ()) {
do {
try tournament.payIfNecessary()
handler()
} catch {
self.showSubscriptionView = true
}
}
private var _networkErrorMessage: String {
ContactManagerError.getNetworkErrorMessage(sentError: sentError, networkMonitorConnected: networkMonitor.connected)
var errors: [String] = []
if networkMonitor.connected == false {
errors.append("L'appareil n'est pas connecté à internet.")
}
if sentError == .mailNotSent {
errors.append("Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer.")
}
if (sentError == .messageFailed || sentError == .messageNotSent) {
errors.append("Le SMS n'a pas été envoyé")
}
if sentError == .mailFailed {
errors.append("Le mail n'a pas été envoyé")
}
return errors.joined(separator: "\n")
}
}
//#Preview {

@ -6,219 +6,23 @@
//
import SwiftUI
import LeStorage
import PadelClubData
struct TeamsCallingView: View {
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var dataStore: DataStore
let teams : [TeamRegistration]
@State private var hideConfirmed: Bool = false
@State private var hideGoodSummoned: Bool = false
@State private var hideSummoned: Bool = false
@State private var searchText: String = ""
var filteredTeams: [TeamRegistration] {
teams
.filter({ hideConfirmed == false || $0.confirmed() == false })
.filter({ hideSummoned == false || $0.called() == false })
.filter({ hideGoodSummoned == false || tournament.isStartDateIsDifferentThanCallDate($0) == true })
.filter({ searchText.isEmpty || $0.contains(searchText) })
}
var anyFilterEnabled: Bool {
hideConfirmed || hideGoodSummoned || hideSummoned
}
var body: some View {
List {
if tournament.isPrivate {
Section {
RowButtonView("Rendre visible sur Padel Club") {
tournament.isPrivate = false
do {
try dataStore.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
} footer: {
Text("Si vous convoquez un tournoi privée, les joueurs n'auront pas le lien pour suivre le tournoi.")
.foregroundStyle(.logoRed)
}
}
PlayersWithoutContactView(players: teams.flatMap({ $0.unsortedPlayers() }).sorted(by: \.computedRank))
let called = teams.filter { tournament.isStartDateIsDifferentThanCallDate($0) == false }
let confirmed = teams.filter { $0.confirmed() }
let justCalled = teams.filter { $0.called() }
let label = "\(justCalled.count.formatted()) / \(teams.count.formatted())"
let subtitle = "dont \(called.count.formatted()) au bon horaire"
let confirmedLabel = "\(confirmed.count.formatted()) / \(teams.count.formatted())"
if teams.isEmpty == false, searchText.isEmpty {
Section {
LabeledContent {
Text(label).font(.title3)
} label: {
Text("Paire\(justCalled.count.pluralSuffix) convoquée\(justCalled.count.pluralSuffix)")
Text(subtitle)
}
LabeledContent {
Text(confirmedLabel).font(.title3)
} label: {
Text("Paire\(confirmed.count.pluralSuffix) confirmée\(confirmed.count.pluralSuffix)")
}
} footer: {
Text("Vous pouvez filtrer cette liste en appuyant sur ") + Text(Image(systemName: "line.3.horizontal.decrease.circle"))
}
}
if filteredTeams.isEmpty == false {
Section {
ForEach(filteredTeams) { team in
TeamCallView(team: team) {
searchText = ""
}
}
} header: {
HStack {
Text("Paire\(filteredTeams.count.pluralSuffix)")
Spacer()
Text(filteredTeams.count.formatted())
}
} footer: {
CallView(teams: filteredTeams)
let teams = tournament.selectedSortedTeams()
Section {
ForEach(teams) { team in
TeamRowView(team: team, displayCallDate: true)
}
} else {
ContentUnavailableView("Aucune équipe", systemImage: "person.2.slash")
}
}
.toolbar(content: {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle(isOn: $hideConfirmed) {
Text("Masquer les confirmées")
}
Toggle(isOn: $hideSummoned) {
Text("Masquer les convoquées")
}
Toggle(isOn: $hideGoodSummoned) {
Text("Masquer les convoquées à la bonne heure")
}
} label: {
LabelFilter()
.symbolVariant(anyFilterEnabled ? .fill : .none)
}
}
})
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.headerProminence(.increased)
.navigationTitle("Statut des convocations")
.navigationTitle("Statut des équipes")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
}
}
struct CallMenuOptionsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Tournament.self) var tournament: Tournament
let team: TeamRegistration
let action: (() -> Void)?
@State private var callDate: Date
init(team: TeamRegistration, action: (() -> Void)? = nil) {
self.team = team
self.action = action
_callDate = .init(wrappedValue: team.expectedSummonDate() ?? team.tournamentObject()?.startDate ?? Date())
}
var confirmed: Binding<Bool> {
Binding {
team.confirmed()
} set: { _ in
team.toggleSummonConfirmation()
_save()
action?()
}
}
private func _save() {
self.tournament.tournamentStore?.teamRegistrations.addOrUpdate(instance: team)
}
var body: some View {
List {
Section {
TeamRowView(team: team, displayCallDate: true)
Toggle(isOn: confirmed) {
Text("Confirmation reçue")
}
DatePicker(selection: $callDate) {
if callDate != team.expectedSummonDate() {
HStack {
Button("Valider", systemImage: "checkmark.circle") {
team.callDate = callDate
_save()
}
.tint(.green)
Divider()
Button("Annuler", systemImage: "xmark.circle", role: .cancel) {
callDate = team.expectedSummonDate() ?? tournament.startDate
}
.tint(.logoRed)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderedProminent)
} else {
Text("Heure de convocation")
}
}
if team.expectedSummonDate() != nil {
CallView(team: team, displayContext: .menu, summonType: .summon)
CallView(team: team, displayContext: .menu, summonType: .summonWalkoutFollowUp)
CallView(team: team, displayContext: .menu, summonType: .summonErrorFollowUp)
}
CallView(team: team, displayContext: .menu, summonType: .contact)
CallView(team: team, displayContext: .menu, summonType: .contactWithoutSignature)
}
Section {
NavigationLink {
EditingTeamView(team: team)
.environment(tournament)
} label: {
Text("Détails de l'équipe")
}
}
Section {
RowButtonView("Effacer la date de convocation", role: .destructive) {
team.callDate = nil
_save()
action?()
dismiss()
}
}
Section {
RowButtonView("Indiquer comme convoquée", role: .destructive) {
team.callDate = team.initialMatch()?.startDate ?? tournament.startDate
_save()
action?()
dismiss()
}
}
}
.navigationTitle("Options de convocation")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save