From a47a0c26ee9fb3ee598c9e6bae645a308fa3ec33 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 1 Jul 2025 07:53:43 +0200 Subject: [PATCH 1/9] add contact info --- PadelClub.xcodeproj/project.pbxproj | 12 +- .../TeamRegistration+Extensions.swift | 11 ++ PadelClub/Utils/PhoneNumbersUtils.swift | 59 +++++++++ .../Calling/Components/MenuWarningView.swift | 18 ++- PadelClub/Views/Player/PlayerDetailView.swift | 113 ++++++++++++++++-- PadelClubTests/ServerDataTests.swift | 6 +- 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 PadelClub/Utils/PhoneNumbersUtils.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index b0c58b3..4157b4f 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -650,6 +650,9 @@ FF7DCD3A2CC330270041110C /* TeamRestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCD382CC330260041110C /* TeamRestingView.swift */; }; FF7DCD3B2CC330270041110C /* TeamRestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCD382CC330260041110C /* TeamRestingView.swift */; }; FF8044AC2C8F676D00A49A52 /* TournamentSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8044AB2C8F676D00A49A52 /* TournamentSubscriptionView.swift */; }; + FF81F1BC2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; + FF81F1BD2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; + FF81F1BE2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; FF82CFC52B911F5B00B0CAF2 /* OrganizedTournamentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF82CFC42B911F5B00B0CAF2 /* OrganizedTournamentView.swift */; }; FF82CFC92B9132AF00B0CAF2 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF82CFC82B9132AF00B0CAF2 /* ActivityView.swift */; }; FF8E1CE62C006E0200184680 /* Alphabet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8E1CE52C006E0200184680 /* Alphabet.swift */; }; @@ -1058,6 +1061,7 @@ FF77CE592CCCD1FF00CBCBB4 /* GroupStageDatePickingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStageDatePickingView.swift; sourceTree = ""; }; FF7DCD382CC330260041110C /* TeamRestingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamRestingView.swift; sourceTree = ""; }; FF8044AB2C8F676D00A49A52 /* TournamentSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentSubscriptionView.swift; sourceTree = ""; }; + FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumbersUtils.swift; sourceTree = ""; }; FF82CFC42B911F5B00B0CAF2 /* OrganizedTournamentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizedTournamentView.swift; sourceTree = ""; }; FF82CFC82B9132AF00B0CAF2 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; FF8E1CE52C006E0200184680 /* Alphabet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alphabet.swift; sourceTree = ""; }; @@ -1935,6 +1939,7 @@ FF0EC51D2BB16F680056B6D1 /* SwiftParser.swift */, FF1DC5582BAB767000FD8220 /* Tips.swift */, C49C731D2D5E3BE4008DD299 /* VersionComparator.swift */, + FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */, ); path = Utils; sourceTree = ""; @@ -2432,6 +2437,7 @@ FF967CF82BAEDF0000A9A3BD /* Labels.swift in Sources */, FFCB74172C480411008384D0 /* CopyPasteButtonView.swift in Sources */, FF089EB42BB0020000F0AEC7 /* PlayerSexPickerView.swift in Sources */, + FF81F1BD2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF1F4B712BF9EFE9000B4573 /* TournamentInscriptionView.swift in Sources */, FF9267FF2BCE94830080F940 /* CallSettingsView.swift in Sources */, FF025ADD2BD0C94300A86CF8 /* FooterButtonView.swift in Sources */, @@ -2695,6 +2701,7 @@ FF4CC0252C996C0600151637 /* Labels.swift in Sources */, FF4CC0262C996C0600151637 /* CopyPasteButtonView.swift in Sources */, FF4CC0272C996C0600151637 /* PlayerSexPickerView.swift in Sources */, + FF81F1BE2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF4CC0282C996C0600151637 /* TournamentInscriptionView.swift in Sources */, FF4CC0292C996C0600151637 /* CallSettingsView.swift in Sources */, FF4CC02A2C996C0600151637 /* FooterButtonView.swift in Sources */, @@ -2936,6 +2943,7 @@ FF70FBA42C90584900129CC2 /* Labels.swift in Sources */, FF70FBA52C90584900129CC2 /* CopyPasteButtonView.swift in Sources */, FF70FBA62C90584900129CC2 /* PlayerSexPickerView.swift in Sources */, + FF81F1BC2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF70FBA72C90584900129CC2 /* TournamentInscriptionView.swift in Sources */, FF70FBA82C90584900129CC2 /* CallSettingsView.swift in Sources */, FF70FBA92C90584900129CC2 /* FooterButtonView.swift in Sources */, @@ -3129,7 +3137,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.40; + MARKETING_VERSION = 1.2.41; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3175,7 +3183,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.40; + MARKETING_VERSION = 1.2.41; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Extensions/TeamRegistration+Extensions.swift b/PadelClub/Extensions/TeamRegistration+Extensions.swift index c3c2140..5cdfd84 100644 --- a/PadelClub/Extensions/TeamRegistration+Extensions.swift +++ b/PadelClub/Extensions/TeamRegistration+Extensions.swift @@ -39,6 +39,17 @@ extension TeamRegistration { 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 diff --git a/PadelClub/Utils/PhoneNumbersUtils.swift b/PadelClub/Utils/PhoneNumbersUtils.swift new file mode 100644 index 0000000..cd446ac --- /dev/null +++ b/PadelClub/Utils/PhoneNumbersUtils.swift @@ -0,0 +1,59 @@ +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) +} diff --git a/PadelClub/Views/Calling/Components/MenuWarningView.swift b/PadelClub/Views/Calling/Components/MenuWarningView.swift index 3f3bcc5..885b1cf 100644 --- a/PadelClub/Views/Calling/Components/MenuWarningView.swift +++ b/PadelClub/Views/Calling/Components/MenuWarningView.swift @@ -69,6 +69,13 @@ 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 @@ -78,6 +85,12 @@ 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") @@ -93,12 +106,13 @@ struct MenuWarningView: View { } fileprivate func _contactByMessage(players: [PlayerRegistration], privateMode: Bool) { - self.savedContactType = .message(date: date, recipients: players.compactMap({ $0.phoneNumber }), body: message, tournamentBuild: nil) + self.savedContactType = .message(date: date, recipients: players.flatMap({ [$0.phoneNumber, $0.contactPhoneNumber].compacted() }), body: message, tournamentBuild: nil) self._tryToContact() } fileprivate func _contactByMail(players: [PlayerRegistration], privateMode: Bool) { - 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) + 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._tryToContact() } diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 8199432..1633df7 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -18,6 +18,12 @@ struct PlayerDetailView: View { @State private var licenceId: String @State private var phoneNumber: String @State private var email: String + + @State private var contactName: String + @State private var contactPhoneNumber: String + @State private var contactEmail: String + + @FocusState var focusedField: PlayerRegistration.CodingKeys? var tournamentStore: TournamentStore? { @@ -29,6 +35,9 @@ struct PlayerDetailView: View { _licenceId = .init(wrappedValue: player.licenceId ?? "") _email = .init(wrappedValue: player.email ?? "") _phoneNumber = .init(wrappedValue: player.phoneNumber ?? "") + _contactName = .init(wrappedValue: player.contactName ?? "") + _contactEmail = .init(wrappedValue: player.contactEmail ?? "") + _contactPhoneNumber = .init(wrappedValue: player.contactPhoneNumber ?? "") } var body: some View { @@ -210,6 +219,18 @@ struct PlayerDetailView: View { } } label: { Menu { + if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: "") { if let url = URL(string: "tel:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "phone") + } + } + if let url = URL(string: "sms:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "message") + } + } + Divider() + } CopyPasteButtonView(pasteValue: player.phoneNumber) PasteButtonView(text: $phoneNumber) } label: { @@ -231,33 +252,103 @@ struct PlayerDetailView: View { } } label: { Menu { + if let mail = player.email, let mailURL = URL(string: "mail:\(mail)") { + Link(destination: mailURL) { + Label(mail, systemImage: "mail") + } + Divider() + } CopyPasteButtonView(pasteValue: player.email) PasteButtonView(text: $email) } label: { Text("Email") } } + } header: { + Text("Information fédérale") } Section { - if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: "") { - if let url = URL(string: "tel:\(number)") { - Link(destination: url) { - Label("Appeler", systemImage: "phone") + LabeledContent { + TextField("Contact/tuteur", text: $contactName) + .focused($focusedField, equals: ._contactName) + .keyboardType(.alphabet) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactName = contactName.prefixTrimmed(200) + _save() } - } - if let url = URL(string: "sms:\(number)") { - Link(destination: url) { - Label("Message", systemImage: "message") + } label: { + Text("Contact/tuteur") + } + + LabeledContent { + TextField("Téléphone contact", text: $contactPhoneNumber) + .focused($focusedField, equals: ._contactPhoneNumber) + .keyboardType(.namePhonePad) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactPhoneNumber = contactPhoneNumber.prefixTrimmed(50) + _save() } + } label: { + Menu { + if let number = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: "") { if let url = URL(string: "tel:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "phone") + } + } + if let url = URL(string: "sms:\(number)") { + Link(destination: url) { + Label(number, systemImage: "message") + } + } + Divider() + } + + CopyPasteButtonView(pasteValue: player.contactPhoneNumber) + PasteButtonView(text: $contactPhoneNumber) + } label: { + Text("Téléphone contact") } } - if let mail = player.email, let mailURL = URL(string: "mail:\(mail)") { - Link(destination: mailURL) { - Label("Mail", systemImage: "mail") + LabeledContent { + TextField("Email contact", text: $contactEmail) + .focused($focusedField, equals: ._contactEmail) + .keyboardType(.emailAddress) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactEmail = contactEmail.prefixTrimmed(50) + _save() + } + } label: { + Menu { + if let mail = player.contactEmail, let mailURL = URL(string: "mail:\(mail)") { + Link(destination: mailURL) { + Label(mail, systemImage: "mail") + } + Divider() + } + CopyPasteButtonView(pasteValue: player.contactEmail) + PasteButtonView(text: $contactEmail) + } label: { + Text("Email contact") } } + } header: { + Text("Information de contact") + } footer: { + Text("Ces champs vous permettent de garder les informations de contacts avec le joueur s'ils sont différents de ceux renvoyées par la base fédérale. Cela permet également de garder le contact d'un parent s'il s'agit d'un tournoi enfant.") } // Section { diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index a0729e0..98735cb 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -310,7 +310,7 @@ final class ServerDataTests: XCTestCase { return } - let playerRegistration = PlayerRegistration(teamRegistration: teamRegistrationId, firstName: "juan", lastName: "lebron", licenceId: "123", rank: 11, paymentType: PlayerPaymentType.cash, sex: PlayerSexType.male, tournamentPlayed: 2, points: 33, clubName: "le club", ligueName: "la league", assimilation: "ass", phoneNumber: "123123", email: "email@email.com", birthdate: nil, computedRank: 222, source: PlayerRegistration.PlayerDataSource.frenchFederation, hasArrived: true, coach: false, captain: false, registeredOnline: false, timeToConfirm: nil, registrationStatus: PlayerRegistration.RegistrationStatus.waiting, paymentId: nil) + let playerRegistration = PlayerRegistration(teamRegistration: teamRegistrationId, firstName: "juan", lastName: "lebron", licenceId: "123", rank: 11, paymentType: PlayerPaymentType.cash, sex: PlayerSexType.male, tournamentPlayed: 2, points: 33, clubName: "le club", ligueName: "la league", assimilation: "ass", phoneNumber: "123123", email: "email@email.com", birthdate: nil, computedRank: 222, source: PlayerRegistration.PlayerDataSource.frenchFederation, hasArrived: true, coach: false, captain: false, registeredOnline: false, timeToConfirm: nil, registrationStatus: PlayerRegistration.RegistrationStatus.waiting, paymentId: nil, contactName: "coach juan", contactPhoneNumber: "4587654321", contactEmail: "juana@email.com") playerRegistration.storeId = "123" if let pr: PlayerRegistration = try await StoreCenter.main.service().post(playerRegistration) { @@ -343,6 +343,10 @@ final class ServerDataTests: XCTestCase { assert(pr.timeToConfirm == playerRegistration.timeToConfirm) assert(pr.registrationStatus == playerRegistration.registrationStatus) assert(pr.paymentId == playerRegistration.paymentId) + assert(pr.contactName == playerRegistration.contactName) + assert(pr.contactEmail == playerRegistration.contactEmail) + assert(pr.contactPhoneNumber == playerRegistration.contactPhoneNumber) + } else { XCTFail("missing data") } From 331103c4c02844cd0ce46ed9329c28d86e24d7e2 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 1 Jul 2025 07:53:43 +0200 Subject: [PATCH 2/9] add contact info --- PadelClub.xcodeproj/project.pbxproj | 12 +- PadelClub/Data/Federal/FederalPlayer.swift | 59 +++++---- .../Extensions/MonthData+Extensions.swift | 16 ++- .../TeamRegistration+Extensions.swift | 11 ++ PadelClub/Utils/PhoneNumbersUtils.swift | 59 +++++++++ .../Calling/Components/MenuWarningView.swift | 18 ++- .../Navigation/Umpire/PadelClubView.swift | 20 ++-- PadelClub/Views/Player/PlayerDetailView.swift | 113 ++++++++++++++++-- PadelClubTests/ServerDataTests.swift | 6 +- 9 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 PadelClub/Utils/PhoneNumbersUtils.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index b0c58b3..2e686ab 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -650,6 +650,9 @@ FF7DCD3A2CC330270041110C /* TeamRestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCD382CC330260041110C /* TeamRestingView.swift */; }; FF7DCD3B2CC330270041110C /* TeamRestingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCD382CC330260041110C /* TeamRestingView.swift */; }; FF8044AC2C8F676D00A49A52 /* TournamentSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8044AB2C8F676D00A49A52 /* TournamentSubscriptionView.swift */; }; + FF81F1BC2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; + FF81F1BD2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; + FF81F1BE2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */; }; FF82CFC52B911F5B00B0CAF2 /* OrganizedTournamentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF82CFC42B911F5B00B0CAF2 /* OrganizedTournamentView.swift */; }; FF82CFC92B9132AF00B0CAF2 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF82CFC82B9132AF00B0CAF2 /* ActivityView.swift */; }; FF8E1CE62C006E0200184680 /* Alphabet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8E1CE52C006E0200184680 /* Alphabet.swift */; }; @@ -1058,6 +1061,7 @@ FF77CE592CCCD1FF00CBCBB4 /* GroupStageDatePickingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStageDatePickingView.swift; sourceTree = ""; }; FF7DCD382CC330260041110C /* TeamRestingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamRestingView.swift; sourceTree = ""; }; FF8044AB2C8F676D00A49A52 /* TournamentSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentSubscriptionView.swift; sourceTree = ""; }; + FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumbersUtils.swift; sourceTree = ""; }; FF82CFC42B911F5B00B0CAF2 /* OrganizedTournamentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizedTournamentView.swift; sourceTree = ""; }; FF82CFC82B9132AF00B0CAF2 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; FF8E1CE52C006E0200184680 /* Alphabet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alphabet.swift; sourceTree = ""; }; @@ -1935,6 +1939,7 @@ FF0EC51D2BB16F680056B6D1 /* SwiftParser.swift */, FF1DC5582BAB767000FD8220 /* Tips.swift */, C49C731D2D5E3BE4008DD299 /* VersionComparator.swift */, + FF81F1BB2E0C4B5F00782CFD /* PhoneNumbersUtils.swift */, ); path = Utils; sourceTree = ""; @@ -2432,6 +2437,7 @@ FF967CF82BAEDF0000A9A3BD /* Labels.swift in Sources */, FFCB74172C480411008384D0 /* CopyPasteButtonView.swift in Sources */, FF089EB42BB0020000F0AEC7 /* PlayerSexPickerView.swift in Sources */, + FF81F1BD2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF1F4B712BF9EFE9000B4573 /* TournamentInscriptionView.swift in Sources */, FF9267FF2BCE94830080F940 /* CallSettingsView.swift in Sources */, FF025ADD2BD0C94300A86CF8 /* FooterButtonView.swift in Sources */, @@ -2695,6 +2701,7 @@ FF4CC0252C996C0600151637 /* Labels.swift in Sources */, FF4CC0262C996C0600151637 /* CopyPasteButtonView.swift in Sources */, FF4CC0272C996C0600151637 /* PlayerSexPickerView.swift in Sources */, + FF81F1BE2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF4CC0282C996C0600151637 /* TournamentInscriptionView.swift in Sources */, FF4CC0292C996C0600151637 /* CallSettingsView.swift in Sources */, FF4CC02A2C996C0600151637 /* FooterButtonView.swift in Sources */, @@ -2936,6 +2943,7 @@ FF70FBA42C90584900129CC2 /* Labels.swift in Sources */, FF70FBA52C90584900129CC2 /* CopyPasteButtonView.swift in Sources */, FF70FBA62C90584900129CC2 /* PlayerSexPickerView.swift in Sources */, + FF81F1BC2E0C4B5F00782CFD /* PhoneNumbersUtils.swift in Sources */, FF70FBA72C90584900129CC2 /* TournamentInscriptionView.swift in Sources */, FF70FBA82C90584900129CC2 /* CallSettingsView.swift in Sources */, FF70FBA92C90584900129CC2 /* FooterButtonView.swift in Sources */, @@ -3129,7 +3137,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.40; + MARKETING_VERSION = 1.2.42; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3175,7 +3183,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.40; + MARKETING_VERSION = 1.2.42; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Federal/FederalPlayer.swift b/PadelClub/Data/Federal/FederalPlayer.swift index d5a6784..3573a3e 100644 --- a/PadelClub/Data/Federal/FederalPlayer.swift +++ b/PadelClub/Data/Federal/FederalPlayer.swift @@ -31,54 +31,70 @@ 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 nationnalite - case anneeNaissance + case nationalite case codeClub case nomClub - case nomLigue - case rang - case progression + case ligue + case classement + case evolution case points - case nombreDeTournois - case assimile + case nombreTournoisJoues + case assimilation + case ageSportif } let container = try decoder.container(keyedBy: CodingKeys.self) isMale = (decoder.userInfo[.maleData] as? Bool) == true - let _lastName = try container.decode(String.self, forKey: .nom) - let _firstName = try container.decode(String.self, forKey: .prenom) - lastName = _lastName - firstName = _firstName + let _lastName = try container.decodeIfPresent(String.self, forKey: .nom) + let _firstName = try container.decodeIfPresent(String.self, forKey: .prenom) + lastName = _lastName ?? "" + firstName = _firstName ?? "" if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) { license = String(lic) } else { license = "" } - let nationnalite = try container.decode(Nationnalite.self, forKey: .nationnalite) - country = nationnalite.code + country = try container.decodeIfPresent(String.self, forKey: .nationalite) ?? "" bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement) - birthYear = try container.decodeIfPresent(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)) ?? 0 + + let ageSportif = try container.decodeIfPresent(Int.self, forKey: .ageSportif) + if let ageSportif { + 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 pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points) if let pointsAsInt { points = Double(pointsAsInt) } else { points = nil } - tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreDeTournois) - let assimile = try container.decode(Bool.self, forKey: .assimile) + tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreTournoisJoues) + let assimile = try container.decode(Bool.self, forKey: .assimilation) assimilation = assimile ? "Oui" : "Non" } @@ -92,6 +108,7 @@ class FederalPlayer: Decodable { } 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 diff --git a/PadelClub/Extensions/MonthData+Extensions.swift b/PadelClub/Extensions/MonthData+Extensions.swift index 0f6bbf6..13d8502 100644 --- a/PadelClub/Extensions/MonthData+Extensions.swift +++ b/PadelClub/Extensions/MonthData+Extensions.swift @@ -15,13 +15,22 @@ 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) - let anonymousCount = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate) + if fftImportingAnonymous == nil { + fftImportingAnonymous = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate) + } + + let anonymousCount: Int? = fftImportingAnonymous + await MainActor.run { let lastDataSource = URL.importDateFormatter.string(from: fromDate) let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource) @@ -30,11 +39,10 @@ extension MonthData { currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0 currentMonthData.incompleteMode = incompleteMode currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1 - currentMonthData.femaleUnrankedValue = lastDataSourceFemaleUnranked?.0 - currentMonthData.femaleCount = lastDataSourceFemaleUnranked?.1 + currentMonthData.femaleUnrankedValue = incompleteMode ? femaleFftImportingMaleUnrankValue : lastDataSourceFemaleUnranked?.0 + currentMonthData.femaleCount = incompleteMode ? femaleFftImportingUncomplete : lastDataSourceFemaleUnranked?.1 currentMonthData.anonymousCount = anonymousCount DataStore.shared.monthData.addOrUpdate(instance: currentMonthData) - } } diff --git a/PadelClub/Extensions/TeamRegistration+Extensions.swift b/PadelClub/Extensions/TeamRegistration+Extensions.swift index c3c2140..5cdfd84 100644 --- a/PadelClub/Extensions/TeamRegistration+Extensions.swift +++ b/PadelClub/Extensions/TeamRegistration+Extensions.swift @@ -39,6 +39,17 @@ extension TeamRegistration { 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 diff --git a/PadelClub/Utils/PhoneNumbersUtils.swift b/PadelClub/Utils/PhoneNumbersUtils.swift new file mode 100644 index 0000000..cd446ac --- /dev/null +++ b/PadelClub/Utils/PhoneNumbersUtils.swift @@ -0,0 +1,59 @@ +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) +} diff --git a/PadelClub/Views/Calling/Components/MenuWarningView.swift b/PadelClub/Views/Calling/Components/MenuWarningView.swift index 3f3bcc5..885b1cf 100644 --- a/PadelClub/Views/Calling/Components/MenuWarningView.swift +++ b/PadelClub/Views/Calling/Components/MenuWarningView.swift @@ -69,6 +69,13 @@ 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 @@ -78,6 +85,12 @@ 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") @@ -93,12 +106,13 @@ struct MenuWarningView: View { } fileprivate func _contactByMessage(players: [PlayerRegistration], privateMode: Bool) { - self.savedContactType = .message(date: date, recipients: players.compactMap({ $0.phoneNumber }), body: message, tournamentBuild: nil) + self.savedContactType = .message(date: date, recipients: players.flatMap({ [$0.phoneNumber, $0.contactPhoneNumber].compacted() }), body: message, tournamentBuild: nil) self._tryToContact() } fileprivate func _contactByMail(players: [PlayerRegistration], privateMode: Bool) { - 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) + 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._tryToContact() } diff --git a/PadelClub/Views/Navigation/Umpire/PadelClubView.swift b/PadelClub/Views/Navigation/Umpire/PadelClubView.swift index ef9a947..29f0145 100644 --- a/PadelClub/Views/Navigation/Umpire/PadelClubView.swift +++ b/PadelClub/Views/Navigation/Umpire/PadelClubView.swift @@ -275,6 +275,14 @@ struct PadelClubView: View { } } + struct RankingJSON: Decodable { + enum CodingKeys: String, CodingKey { + case joueurs + } + + let joueurs: [FederalPlayer] + } + private func _exportCsv() async { for fileURL in SourceFileManager.shared.jsonFiles() { let decoder = JSONDecoder() @@ -282,18 +290,16 @@ struct PadelClubView: View { do { let data = try Data(contentsOf: fileURL) - let players = try decoder.decode([FederalPlayer].self, from: data) + let players = try decoder.decode(RankingJSON.self, from: data).joueurs var anonymousPlayers = players.filter { $0.firstName.isEmpty && $0.lastName.isEmpty } - let okPlayers = players.filter { $0.firstName.isEmpty == false && $0.lastName.isEmpty == false } print("before anonymousPlayers.count", anonymousPlayers.count) - FileImportManager.shared.updatePlayers(isMale: fileURL.manData, players: &anonymousPlayers) - print("after local anonymousPlayers.count", anonymousPlayers.filter { $0.firstName.isEmpty && $0.lastName.isEmpty }.count) + //FileImportManager.shared.updatePlayers(isMale: fileURL.manData, players: &anonymousPlayers) +// print("after local anonymousPlayers.count", anonymousPlayers.filter { $0.firstName.isEmpty && $0.lastName.isEmpty }.count) - await fetchPlayersDataSequentially(for: &anonymousPlayers) + //await fetchPlayersDataSequentially(for: &anonymousPlayers) - print("after beach anonymousPlayers.count", anonymousPlayers.filter { $0.firstName.isEmpty && $0.lastName.isEmpty } - .count) +// print("after beach anonymousPlayers.count", anonymousPlayers.filter { $0.firstName.isEmpty && $0.lastName.isEmpty }.count) SourceFileManager.shared.exportToCSV(players: players, sourceFileType: fileURL.manData ? .messieurs : .dames, date: fileURL.dateFromPath) SourceFileManager.shared.exportToCSV("anonymes", players: anonymousPlayers.filter { $0.firstName.isEmpty && $0.lastName.isEmpty }, sourceFileType: fileURL.manData ? .messieurs : .dames, date: fileURL.dateFromPath) } catch { diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 8199432..1633df7 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -18,6 +18,12 @@ struct PlayerDetailView: View { @State private var licenceId: String @State private var phoneNumber: String @State private var email: String + + @State private var contactName: String + @State private var contactPhoneNumber: String + @State private var contactEmail: String + + @FocusState var focusedField: PlayerRegistration.CodingKeys? var tournamentStore: TournamentStore? { @@ -29,6 +35,9 @@ struct PlayerDetailView: View { _licenceId = .init(wrappedValue: player.licenceId ?? "") _email = .init(wrappedValue: player.email ?? "") _phoneNumber = .init(wrappedValue: player.phoneNumber ?? "") + _contactName = .init(wrappedValue: player.contactName ?? "") + _contactEmail = .init(wrappedValue: player.contactEmail ?? "") + _contactPhoneNumber = .init(wrappedValue: player.contactPhoneNumber ?? "") } var body: some View { @@ -210,6 +219,18 @@ struct PlayerDetailView: View { } } label: { Menu { + if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: "") { if let url = URL(string: "tel:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "phone") + } + } + if let url = URL(string: "sms:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "message") + } + } + Divider() + } CopyPasteButtonView(pasteValue: player.phoneNumber) PasteButtonView(text: $phoneNumber) } label: { @@ -231,33 +252,103 @@ struct PlayerDetailView: View { } } label: { Menu { + if let mail = player.email, let mailURL = URL(string: "mail:\(mail)") { + Link(destination: mailURL) { + Label(mail, systemImage: "mail") + } + Divider() + } CopyPasteButtonView(pasteValue: player.email) PasteButtonView(text: $email) } label: { Text("Email") } } + } header: { + Text("Information fédérale") } Section { - if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: "") { - if let url = URL(string: "tel:\(number)") { - Link(destination: url) { - Label("Appeler", systemImage: "phone") + LabeledContent { + TextField("Contact/tuteur", text: $contactName) + .focused($focusedField, equals: ._contactName) + .keyboardType(.alphabet) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactName = contactName.prefixTrimmed(200) + _save() } - } - if let url = URL(string: "sms:\(number)") { - Link(destination: url) { - Label("Message", systemImage: "message") + } label: { + Text("Contact/tuteur") + } + + LabeledContent { + TextField("Téléphone contact", text: $contactPhoneNumber) + .focused($focusedField, equals: ._contactPhoneNumber) + .keyboardType(.namePhonePad) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactPhoneNumber = contactPhoneNumber.prefixTrimmed(50) + _save() } + } label: { + Menu { + if let number = player.contactPhoneNumber?.replacingOccurrences(of: " ", with: "") { if let url = URL(string: "tel:\(number)") { + Link(destination: url) { + Label("\(number)", systemImage: "phone") + } + } + if let url = URL(string: "sms:\(number)") { + Link(destination: url) { + Label(number, systemImage: "message") + } + } + Divider() + } + + CopyPasteButtonView(pasteValue: player.contactPhoneNumber) + PasteButtonView(text: $contactPhoneNumber) + } label: { + Text("Téléphone contact") } } - if let mail = player.email, let mailURL = URL(string: "mail:\(mail)") { - Link(destination: mailURL) { - Label("Mail", systemImage: "mail") + LabeledContent { + TextField("Email contact", text: $contactEmail) + .focused($focusedField, equals: ._contactEmail) + .keyboardType(.emailAddress) + .textContentType(nil) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + player.contactEmail = contactEmail.prefixTrimmed(50) + _save() + } + } label: { + Menu { + if let mail = player.contactEmail, let mailURL = URL(string: "mail:\(mail)") { + Link(destination: mailURL) { + Label(mail, systemImage: "mail") + } + Divider() + } + CopyPasteButtonView(pasteValue: player.contactEmail) + PasteButtonView(text: $contactEmail) + } label: { + Text("Email contact") } } + } header: { + Text("Information de contact") + } footer: { + Text("Ces champs vous permettent de garder les informations de contacts avec le joueur s'ils sont différents de ceux renvoyées par la base fédérale. Cela permet également de garder le contact d'un parent s'il s'agit d'un tournoi enfant.") } // Section { diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index a0729e0..98735cb 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -310,7 +310,7 @@ final class ServerDataTests: XCTestCase { return } - let playerRegistration = PlayerRegistration(teamRegistration: teamRegistrationId, firstName: "juan", lastName: "lebron", licenceId: "123", rank: 11, paymentType: PlayerPaymentType.cash, sex: PlayerSexType.male, tournamentPlayed: 2, points: 33, clubName: "le club", ligueName: "la league", assimilation: "ass", phoneNumber: "123123", email: "email@email.com", birthdate: nil, computedRank: 222, source: PlayerRegistration.PlayerDataSource.frenchFederation, hasArrived: true, coach: false, captain: false, registeredOnline: false, timeToConfirm: nil, registrationStatus: PlayerRegistration.RegistrationStatus.waiting, paymentId: nil) + let playerRegistration = PlayerRegistration(teamRegistration: teamRegistrationId, firstName: "juan", lastName: "lebron", licenceId: "123", rank: 11, paymentType: PlayerPaymentType.cash, sex: PlayerSexType.male, tournamentPlayed: 2, points: 33, clubName: "le club", ligueName: "la league", assimilation: "ass", phoneNumber: "123123", email: "email@email.com", birthdate: nil, computedRank: 222, source: PlayerRegistration.PlayerDataSource.frenchFederation, hasArrived: true, coach: false, captain: false, registeredOnline: false, timeToConfirm: nil, registrationStatus: PlayerRegistration.RegistrationStatus.waiting, paymentId: nil, contactName: "coach juan", contactPhoneNumber: "4587654321", contactEmail: "juana@email.com") playerRegistration.storeId = "123" if let pr: PlayerRegistration = try await StoreCenter.main.service().post(playerRegistration) { @@ -343,6 +343,10 @@ final class ServerDataTests: XCTestCase { assert(pr.timeToConfirm == playerRegistration.timeToConfirm) assert(pr.registrationStatus == playerRegistration.registrationStatus) assert(pr.paymentId == playerRegistration.paymentId) + assert(pr.contactName == playerRegistration.contactName) + assert(pr.contactEmail == playerRegistration.contactEmail) + assert(pr.contactPhoneNumber == playerRegistration.contactPhoneNumber) + } else { XCTFail("missing data") } From 3781aac0902bd19c1cce9feddef05c4a538d007b Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Fri, 4 Jul 2025 08:46:24 +0200 Subject: [PATCH 3/9] add planned date display in print options --- PadelClub.xcodeproj/project.pbxproj | 4 +- PadelClub/HTML Templates/match-template.html | 1 + .../HTML Templates/tournament-template.html | 8 ++ PadelClub/Utils/HtmlGenerator.swift | 9 +- PadelClub/Utils/HtmlService.swift | 85 +++++++++++++------ .../Tournament/Screen/PrintSettingsView.swift | 12 ++- 6 files changed, 83 insertions(+), 36 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 2e686ab..9cadd62 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3137,7 +3137,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.42; + MARKETING_VERSION = 1.2.43; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3183,7 +3183,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.42; + MARKETING_VERSION = 1.2.43; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/HTML Templates/match-template.html b/PadelClub/HTML Templates/match-template.html index bf0365d..1f6b761 100644 --- a/PadelClub/HTML Templates/match-template.html +++ b/PadelClub/HTML Templates/match-template.html @@ -3,6 +3,7 @@
{{matchDescriptionTop}}
  • +
    {{centerMatchText}}
  • diff --git a/PadelClub/HTML Templates/tournament-template.html b/PadelClub/HTML Templates/tournament-template.html index 03c77d2..72d6621 100644 --- a/PadelClub/HTML Templates/tournament-template.html +++ b/PadelClub/HTML Templates/tournament-template.html @@ -113,6 +113,14 @@ 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 */ + } diff --git a/PadelClub/Utils/HtmlGenerator.swift b/PadelClub/Utils/HtmlGenerator.swift index 6ec685a..cd61cc0 100644 --- a/PadelClub/Utils/HtmlGenerator.swift +++ b/PadelClub/Utils/HtmlGenerator.swift @@ -27,6 +27,7 @@ class HtmlGenerator: ObservableObject { @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] = [] @@ -179,12 +180,16 @@ class HtmlGenerator: ObservableObject { func generateHtml() -> String { //HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html() - HtmlService.template(tournament: tournament).html(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore) + HtmlService.template(tournament: tournament).html(options: options) } func generateLoserBracketHtml(upperRound: Round) -> String { //HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html() - HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore) + HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(options: options) + } + + var options: HtmlOptions { + HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate) } var pdfURL: URL? { diff --git a/PadelClub/Utils/HtmlService.swift b/PadelClub/Utils/HtmlService.swift index da7e2f0..292f11f 100644 --- a/PadelClub/Utils/HtmlService.swift +++ b/PadelClub/Utils/HtmlService.swift @@ -8,6 +8,29 @@ import Foundation import PadelClubData +struct HtmlOptions { + let headName: Bool + let withRank: Bool + let withTeamIndex: Bool + let withScore: Bool + let withPlannedDate: 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 + ) { + self.headName = headName + self.withRank = withRank + self.withTeamIndex = withTeamIndex + self.withScore = withScore + self.withPlannedDate = withPlannedDate + } +} + enum HtmlService { case template(tournament: Tournament) @@ -51,7 +74,7 @@ enum HtmlService { } } - func html(headName: Bool, withRank: Bool, withTeamIndex: Bool, withScore: Bool) -> String { + func html(options: HtmlOptions = HtmlOptions()) -> String { guard let file = Bundle.main.path(forResource: self.fileName, ofType: "html") else { fatalError() } @@ -74,8 +97,8 @@ enum HtmlService { var col = "" var row = "" bracket.teams().forEach { entrant in - col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) - row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + 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)) } template = template.replacingOccurrences(of: "{{teamsCol}}", with: col) template = template.replacingOccurrences(of: "{{teamsRow}}", with: row) @@ -83,7 +106,7 @@ enum HtmlService { return template case .groupstageEntrant(let entrant): var template = html - if withTeamIndex == false { + if options.withTeamIndex == false { template = template.replacingOccurrences(of: #"
    {{teamIndex}}
    "#, with: "") } else { template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.seedIndex() ?? "") @@ -91,7 +114,7 @@ enum HtmlService { if let playerOne = entrant.players()[safe: 0] { template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel()) - if withRank { + if options.withRank { template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))") } else { template = template.replacingOccurrences(of: "{{weightOne}}", with: "") @@ -103,7 +126,7 @@ enum HtmlService { if let playerTwo = entrant.players()[safe: 1] { template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel()) - if withRank { + if options.withRank { template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))") } else { template = template.replacingOccurrences(of: "{{weightTwo}}", with: "") @@ -115,7 +138,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(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(options: options)) var scores = "" (0..{{teamIndex}}
    "#, with: "") } else { template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.formattedSeed()) @@ -155,7 +178,7 @@ enum HtmlService { if let playerOne = entrant.players()[safe: 0] { template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel()) - if withRank { + if options.withRank { template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))") } else { template = template.replacingOccurrences(of: "{{weightOne}}", with: "") @@ -167,7 +190,7 @@ enum HtmlService { if let playerTwo = entrant.players()[safe: 1] { template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel()) - if withRank { + if options.withRank { template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))") } else { template = template.replacingOccurrences(of: "{{weightTwo}}", with: "") @@ -179,27 +202,32 @@ enum HtmlService { return template case .hiddenPlayer: var template = html + html - if withTeamIndex { + if options.withTeamIndex { template += html } return template 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(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) - if withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() { + 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")) } } else { - template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(options: options)) } if let entrantTwo = match.team(.two) { - template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) - if withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() { + 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")) } } else { - template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(options: options)) } if match.disabled { template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden") @@ -216,12 +244,13 @@ enum HtmlService { } template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: "") template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: "") + template = template.replacingOccurrences(of: "{{centerMatchText}}", 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(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + template = template.appending(HtmlService.match(match: match).html(options: options)) } bracket = html.replacingOccurrences(of: "{{match-template}}", with: template) @@ -230,7 +259,7 @@ enum HtmlService { return bracket case .loserBracket(let upperRound, let hideTitle): var template = html - template = template.replacingOccurrences(of: "{{minHeight}}", with: withTeamIndex ? "226" : "156") + 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) @@ -242,10 +271,10 @@ enum HtmlService { var brackets = "" for round in upperRound.loserRounds() { - brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options)) if round.index == 1 { - let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore) + let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options) template = template.appending(sub) } } @@ -265,7 +294,7 @@ enum HtmlService { for round in upperRound.loserRounds() { if round.index > 1 { - let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore) + let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options) template = template.appending(sub) } } @@ -273,17 +302,17 @@ enum HtmlService { return template case .template(let tournament): var template = html - template = template.replacingOccurrences(of: "{{minHeight}}", with: withTeamIndex ? "226" : "156") + 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()) var brackets = "" for round in tournament.rounds() { - brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options)) } var winnerName = "" if let tournamentWinner = tournament.tournamentWinner() { - winnerName = HtmlService.player(entrant: tournamentWinner).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore) + winnerName = HtmlService.player(entrant: tournamentWinner).html(options: options) } let winner = """
      diff --git a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift index 0693086..3caae86 100644 --- a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift @@ -28,7 +28,11 @@ struct PrintSettingsView: View { Section { // Toggle(isOn: $generator.displayHeads, label: { // Text("Afficher les têtes de séries") -// }) +// } + + Toggle(isOn: $generator.displayPlannedDate, label: { + Text("Afficher la date plannifiée") + }) Toggle(isOn: $generator.displayTeamIndex, label: { Text("Afficher le poids et le rang de l'équipe") @@ -178,7 +182,7 @@ struct PrintSettingsView: View { } if let groupStage = tournament.groupStages().first { - ShareLink(item: HtmlService.groupstage(groupStage: groupStage).html(headName: generator.displayHeads, withRank: generator.displayRank, withTeamIndex: generator.displayTeamIndex, withScore: generator.displayScore)) { + ShareLink(item: HtmlService.groupstage(groupStage: groupStage).html(options: generator.options)) { Text("Poule") } } @@ -219,7 +223,7 @@ struct PrintSettingsView: View { Group { if prepareGroupStage { ForEach(tournament.groupStages()) { groupStage in - WebView(htmlRawData: HtmlService.groupstage(groupStage: groupStage).html(headName: generator.displayHeads, withRank: generator.displayRank, withTeamIndex: generator.displayTeamIndex, withScore: generator.displayScore), loadStatusChanged: { loaded, error, webView in + WebView(htmlRawData: HtmlService.groupstage(groupStage: groupStage).html(options: generator.options), loadStatusChanged: { loaded, error, webView in if let error { print("preparePDF", error) } else if loaded == false { @@ -321,7 +325,7 @@ struct WebViewPreview: View { ProgressView() .onAppear { if let groupStage { - html = HtmlService.groupstage(groupStage: groupStage).html(headName: generator.displayHeads, withRank: generator.displayRank, withTeamIndex: generator.displayTeamIndex, withScore: generator.displayScore) + html = HtmlService.groupstage(groupStage: groupStage).html(options: generator.options) } else if let round { html = generator.generateLoserBracketHtml(upperRound: round) } else { From ae58efd2f70f320b3154f24dccb2bce135947642 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Fri, 4 Jul 2025 10:01:17 +0200 Subject: [PATCH 4/9] fix loserbracket missing from pdf --- PadelClub.xcodeproj/project.pbxproj | 4 +- PadelClub/Utils/HtmlGenerator.swift | 2 +- PadelClub/Utils/HtmlService.swift | 22 +++- .../Tournament/Screen/PrintSettingsView.swift | 122 +++++++++--------- 4 files changed, 87 insertions(+), 63 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 9cadd62..5cf11b1 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3137,7 +3137,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.43; + MARKETING_VERSION = 1.2.44; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3183,7 +3183,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.43; + MARKETING_VERSION = 1.2.44; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Utils/HtmlGenerator.swift b/PadelClub/Utils/HtmlGenerator.swift index cd61cc0..f91c736 100644 --- a/PadelClub/Utils/HtmlGenerator.swift +++ b/PadelClub/Utils/HtmlGenerator.swift @@ -189,7 +189,7 @@ class HtmlGenerator: ObservableObject { } var options: HtmlOptions { - HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate) + HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate, includeLoserBracket: includeLoserBracket) } var pdfURL: URL? { diff --git a/PadelClub/Utils/HtmlService.swift b/PadelClub/Utils/HtmlService.swift index 292f11f..dcc92dc 100644 --- a/PadelClub/Utils/HtmlService.swift +++ b/PadelClub/Utils/HtmlService.swift @@ -14,6 +14,7 @@ struct HtmlOptions { let withTeamIndex: Bool let withScore: Bool let withPlannedDate: Bool + let includeLoserBracket: Bool // Default initializer with all options defaulting to true init( @@ -21,13 +22,15 @@ struct HtmlOptions { withRank: Bool = true, withTeamIndex: Bool = true, withScore: Bool = true, - withPlannedDate: 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 } } @@ -308,6 +311,13 @@ enum HtmlService { 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) + } + } } var winnerName = "" @@ -326,6 +336,16 @@ 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 } } diff --git a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift index 3caae86..2cc6c92 100644 --- a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift @@ -50,11 +50,17 @@ struct PrintSettingsView: View { Toggle(isOn: $generator.includeBracket, label: { Text("Tableau") }) + .onChange(of: generator.includeBracket) { oldValue, newValue in + if newValue == false { + generator.includeLoserBracket = newValue + } + } Toggle(isOn: $generator.includeLoserBracket, label: { Text("Tableau des matchs de classements") }) - + .disabled(generator.includeBracket == false) + if tournament.groupStages().isEmpty == false { Toggle(isOn: $generator.includeGroupStage, label: { Text("Poules") @@ -62,71 +68,69 @@ struct PrintSettingsView: View { } } - if generator.includeBracket { - Section { - Picker(selection: $generator.zoomLevel) { - Text("1 page").tag(nil as Optional) - Text("50%").tag(2.0 as Optional) - Text("100%").tag(1.0 as Optional) - } label: { - Text("Zoom") - } - .onChange(of: generator.zoomLevel) { - if generator.zoomLevel == nil { - generator.landscape = false - } - } - - if generator.zoomLevel != nil { - Toggle(isOn: $generator.landscape, label: { - Text("Format paysage") - }) - } - - HStack { - Text("Nombre de page A4 à imprimer") - Spacer() - Text(generator.estimatedPageCount.formatted()) + Section { + Picker(selection: $generator.zoomLevel) { + Text("1 page").tag(nil as Optional) + Text("50%").tag(2.0 as Optional) + Text("100%").tag(1.0 as Optional) + } label: { + Text("Zoom") + } + .onChange(of: generator.zoomLevel) { + if generator.zoomLevel == nil { + generator.landscape = false } - } header: { - Text("Tableau principal") } - if generating == false { - RowButtonView("Générer le PDF", systemImage: "printer") { - await MainActor.run() { - self.generating = true - } - generator.preparePDF { result in - switch result { - case .success(true): - if generator.includeGroupStage && generator.groupStageIsReady == false && tournament.groupStages().isEmpty == false { - self.prepareGroupStage = true - self.generationGroupStageId = UUID() - } else { - self.presentShareView = true - self.generating = false - } - case .success(false): - print("didn't save pdf") - break - case .failure(let error): - print(error) - break + if generator.zoomLevel != nil { + Toggle(isOn: $generator.landscape, label: { + Text("Format paysage") + }) + } + + HStack { + Text("Nombre de page A4 à imprimer") + Spacer() + Text(generator.estimatedPageCount.formatted()) + } + } header: { + Text("Tableau principal") + } + + if generating == false { + RowButtonView("Générer le PDF", systemImage: "printer") { + await MainActor.run() { + self.generating = true + } + generator.preparePDF { result in + switch result { + case .success(true): + if generator.includeGroupStage && generator.groupStageIsReady == false && tournament.groupStages().isEmpty == false { + self.prepareGroupStage = true + self.generationGroupStageId = UUID() + } else { + self.presentShareView = true + self.generating = false } + case .success(false): + print("didn't save pdf") + break + case .failure(let error): + print(error) + break } - self.prepareGroupStage = false - self.generationId = UUID() - } - .disabled(generator.includeBracket == false && generator.includeGroupStage == false && generator.includeLoserBracket == false) - } else { - LabeledContent { - ProgressView() - } label: { - Text("Préparation du PDF") } - .id(generationId) + self.prepareGroupStage = false + self.generationId = UUID() + } + .disabled(generator.includeBracket == false && generator.includeGroupStage == false) + } else { + LabeledContent { + ProgressView() + } label: { + Text("Préparation du PDF") } + .id(generationId) } Section { From 908edea49489a06a34aa215648f5f38121c46353 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 9 Jul 2025 17:50:45 +0200 Subject: [PATCH 5/9] fix issue with fft search --- PadelClub.xcodeproj/project.pbxproj | 12 +- .../Data/Federal/FederalTournament.swift | 9 +- .../Utils/Network/FederalDataService.swift | 368 ++++++++++++++++++ .../Utils/Network/NetworkFederalService.swift | 205 +--------- .../ViewModel/FederalDataViewModel.swift | 4 +- PadelClub/Views/Club/ClubSearchView.swift | 23 +- .../Agenda/TournamentLookUpView.swift | 85 ++-- 7 files changed, 439 insertions(+), 267 deletions(-) create mode 100644 PadelClub/Utils/Network/FederalDataService.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 5cf11b1..5d34828 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -753,6 +753,9 @@ FFCFC01A2BBC5A8500B82851 /* MatchFormatRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCFC0192BBC5A8500B82851 /* MatchFormatRowView.swift */; }; FFD655D82C8DE27400E5B35E /* TournamentLookUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD655D72C8DE27400E5B35E /* TournamentLookUpView.swift */; }; FFD783FF2B91BA42000F62A6 /* PadelClubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD783FE2B91BA42000F62A6 /* PadelClubView.swift */; }; + FFD883792E1E63880004D7DD /* FederalDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD883782E1E63880004D7DD /* FederalDataService.swift */; }; + FFD8837A2E1E63880004D7DD /* FederalDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD883782E1E63880004D7DD /* FederalDataService.swift */; }; + FFD8837B2E1E63880004D7DD /* FederalDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD883782E1E63880004D7DD /* FederalDataService.swift */; }; FFDDD40C2B93B2BB00C91A49 /* DeferredViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDDD40B2B93B2BB00C91A49 /* DeferredViewModifier.swift */; }; FFE103082C353B7600684FC9 /* EventClubSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE103072C353B7600684FC9 /* EventClubSettingsView.swift */; }; FFE103102C366DCD00684FC9 /* EditSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE1030F2C366DCD00684FC9 /* EditSharingView.swift */; }; @@ -1144,6 +1147,7 @@ FFD655D72C8DE27400E5B35E /* TournamentLookUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentLookUpView.swift; sourceTree = ""; }; FFD783FE2B91BA42000F62A6 /* PadelClubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadelClubView.swift; sourceTree = ""; }; FFD784002B91BF79000F62A6 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; + FFD883782E1E63880004D7DD /* FederalDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalDataService.swift; sourceTree = ""; }; FFDDD40B2B93B2BB00C91A49 /* DeferredViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredViewModifier.swift; sourceTree = ""; }; FFE103072C353B7600684FC9 /* EventClubSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EventClubSettingsView.swift; path = PadelClub/Views/Tournament/Screen/Components/EventClubSettingsView.swift; sourceTree = SOURCE_ROOT; }; FFE1030F2C366DCD00684FC9 /* EditSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSharingView.swift; sourceTree = ""; }; @@ -1743,6 +1747,7 @@ FFE8B5C62DAA390000BDE966 /* StripeValidationService.swift */, FFE8B5CA2DAA429E00BDE966 /* XlsToCsvService.swift */, FFE8B6392DACEAEC00BDE966 /* ConfigurationService.swift */, + FFD883782E1E63880004D7DD /* FederalDataService.swift */, ); path = Network; sourceTree = ""; @@ -2359,6 +2364,7 @@ FF5D30512BD94E1000F2B93D /* ImportedPlayer+Extensions.swift in Sources */, FFC1E1042BAC28C6008D6F59 /* ClubSearchView.swift in Sources */, FFE8B5B72DA8763800BDE966 /* PaymentInfoSheetView.swift in Sources */, + FFD8837A2E1E63880004D7DD /* FederalDataService.swift in Sources */, FFBFC3962CF05CBB000EBD8D /* DateMenuView.swift in Sources */, FF089EBB2BB0120700F0AEC7 /* PlayerPopoverView.swift in Sources */, FF70916E2B9108C600AB08DA /* InscriptionManagerView.swift in Sources */, @@ -2623,6 +2629,7 @@ FF4CBFDB2C996C0600151637 /* InscriptionManagerView.swift in Sources */, FFB378362D672ED200EE82E9 /* MatchFormatGuideView.swift in Sources */, FF77CE522CCCD1B200CBCBB4 /* MatchFormatPickingView.swift in Sources */, + FFD883792E1E63880004D7DD /* FederalDataService.swift in Sources */, FF4CBFDC2C996C0600151637 /* ActivityView.swift in Sources */, FF4CBFDE2C996C0600151637 /* CalendarView.swift in Sources */, FF4CBFDF2C996C0600151637 /* FederalTournamentSearchScope.swift in Sources */, @@ -2865,6 +2872,7 @@ FF70FB5A2C90584900129CC2 /* InscriptionManagerView.swift in Sources */, FFB378342D672ED200EE82E9 /* MatchFormatGuideView.swift in Sources */, FF77CE532CCCD1B200CBCBB4 /* MatchFormatPickingView.swift in Sources */, + FFD8837B2E1E63880004D7DD /* FederalDataService.swift in Sources */, FF70FB5B2C90584900129CC2 /* ActivityView.swift in Sources */, FF70FB5D2C90584900129CC2 /* CalendarView.swift in Sources */, FF70FB5E2C90584900129CC2 /* FederalTournamentSearchScope.swift in Sources */, @@ -3137,7 +3145,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.44; + MARKETING_VERSION = 1.2.45; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3183,7 +3191,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.44; + MARKETING_VERSION = 1.2.45; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Federal/FederalTournament.swift b/PadelClub/Data/Federal/FederalTournament.swift index 0021712..cb214d4 100644 --- a/PadelClub/Data/Federal/FederalTournament.swift +++ b/PadelClub/Data/Federal/FederalTournament.swift @@ -341,17 +341,10 @@ struct CategoriesAgeTypePratique: Codable { // MARK: - ID struct ID: Codable { - var typePratique: TypePratique? + var typePratique: String? var idCategorieAge: Int? } -enum TypePratique: String, Codable { - case beach = "BEACH" - case padel = "PADEL" - case tennis = "TENNIS" - case pickle = "PICKLE" -} - // MARK: - CategorieTournoi struct CategorieTournoi: Codable { var code, codeTaxe: String? diff --git a/PadelClub/Utils/Network/FederalDataService.swift b/PadelClub/Utils/Network/FederalDataService.swift new file mode 100644 index 0000000..9cf9bf0 --- /dev/null +++ b/PadelClub/Utils/Network/FederalDataService.swift @@ -0,0 +1,368 @@ +// +// 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/' + let urlRequest = try service._baseRequest(servicePath: "fft/all-tournaments?\(queryString)", method: .get, requiresToken: true) + + 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)/" + let urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false) + + 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)") + } + } + + + /// Fetches umpire contact data for multiple tournament IDs. + /// This function calls your backend endpoint that handles multiple tournament IDs via query parameters. + /// - Parameter tournamentIds: An array of tournament ID strings. + /// - Returns: A dictionary mapping tournament IDs to tuples `(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 getUmpiresData(tournamentIds: [String]) async throws -> [String: (name: String?, email: String?, phone: String?)] { + let service = try StoreCenter.main.service() + + // Validate input + guard !tournamentIds.isEmpty else { + throw NetworkManagerError.apiError("Tournament IDs array cannot be empty") + } + + // Create the base service path + let basePath = "fft/umpires/" + + // Build query parameters - join tournament IDs with commas + let tournamentIdsParam = tournamentIds.joined(separator: ",") + let queryItems = [URLQueryItem(name: "tournament_ids", value: tournamentIdsParam)] + + // Create the URL with query parameters + var urlComponents = URLComponents() + urlComponents.queryItems = queryItems + + let servicePath = basePath + (urlComponents.url?.query.map { "?\($0)" } ?? "") + + let urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false) + + 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 + } + + // Check for HTTP errors + guard httpResponse.statusCode == 200 else { + if let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorData["message"] as? String { + throw NetworkManagerError.apiError("Server error: \(message)") + } + throw NetworkManagerError.apiError("HTTP error: \(httpResponse.statusCode)") + } + + do { + let umpireResponse = try JSONDecoder().decode(UmpireDataResponse.self, from: data) + + // Convert the results to the expected return format + var resultDict: [String: (name: String?, email: String?, phone: String?)] = [:] + + for (tournamentId, umpireInfo) in umpireResponse.results { + resultDict[tournamentId] = (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone) + } + + print("Umpire data fetched for \(resultDict.count) tournaments") + return resultDict + + } catch { + print("Decoding error for UmpireDataResponse: \(error)") + throw NetworkManagerError.apiError("Failed to decode UmpireDataResponse: \(error.localizedDescription)") + } + } + +} diff --git a/PadelClub/Utils/Network/NetworkFederalService.swift b/PadelClub/Utils/Network/NetworkFederalService.swift index 66d013d..2d09be2 100644 --- a/PadelClub/Utils/Network/NetworkFederalService.swift +++ b/PadelClub/Utils/Network/NetworkFederalService.swift @@ -67,6 +67,7 @@ class NetworkFederalService { 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", @@ -114,211 +115,11 @@ class NetworkFederalService { func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> [FederalTournament] { - - 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") - - 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 = "\"/> [HttpCommand] { - - var cityParameter = "" - var searchType = "ligue" - if city.trimmed.isEmpty == false { - searchType = "ville" - cityParameter = city - } - - var levelsParameter = "" - if levels.isEmpty == false { - levelsParameter = levels.map { "categorie_tournoi[\($0.searchRawValue())]=\($0.searchRawValue())" }.joined(separator: "&") + "&" - } - - var categoriesParameter = "" - if categories.isEmpty == false { - categoriesParameter = categories.map { "epreuve[\($0.requestLabel)]=\($0.requestLabel)" }.joined(separator: "&") + "&" - } - - var agesParameter = "" - if ages.isEmpty == false { - agesParameter = ages.map { "categorie_age[\($0.rawValue)]=\($0.rawValue)" }.joined(separator: "&") + "&" - } - - var typesParameter = "" - if types.isEmpty == false { - typesParameter = types.map { "type[\($0.rawValue.capitalized)]=\($0.rawValue.capitalized)" }.joined(separator: "&") + "&" - } - - var npc = "" - if nationalCup { - npc = "&tournoi_npc=1" - } - - let parameters = """ -recherche_type=\(searchType)&ville%5Bautocomplete%5D%5Bcountry%5D=fr&ville%5Bautocomplete%5D%5Btextfield%5D=&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blat_field%5D=\(lat ?? "")&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blng_field%5D=\(lng ?? "")&ville%5Bdistance%5D%5Bvalue_field%5D=\(Int(distance))&club%5Bautocomplete%5D%5Btextfield%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=&pratique=PADEL&date%5Bstart%5D=\(startDate.twoDigitsYearFormatted)&date%5Bend%5D=\(endDate.twoDigitsYearFormatted)&\(categoriesParameter)\(levelsParameter)\(agesParameter)\(typesParameter)\(npc)&page=\(page)&sort=\(sortingOption)&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") - - request.httpMethod = "POST" - request.httpBody = postData - - - return try await runTenupTask(request: request) + return try await FederalDataService.shared.getClubFederalTournaments(page: page, tournaments: tournaments, club: club, codeClub: codeClub, startDate: startDate, endDate: endDate).tournaments } func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) { - guard let url = URL(string: "https://tenup.fft.fr/tournoi/\(idTournament)") else { - throw URLError(.badURL) - } - - let (data, _) = try await URLSession.shared.data(from: url) - - guard let htmlString = String(data: data, encoding: .utf8) else { - throw URLError(.cannotDecodeContentData) - } - - let namePattern = "tournoi-detail-page-inscription-responsable-title\">\\s*([^<]+)\\s*<" - let nameRegex = try? NSRegularExpression(pattern: namePattern) - let nameMatch = nameRegex?.firstMatch(in: htmlString, range: NSRange(htmlString.startIndex..., in: htmlString)) - let name = nameMatch.flatMap { match in - Range(match.range(at: 1), in: htmlString) - }.map { range in - String(htmlString[range]).trimmingCharacters(in: .whitespacesAndNewlines) - } - - // Extract email using regex - let emailPattern = "mailto:([^\"]+)\"" - let emailRegex = try? NSRegularExpression(pattern: emailPattern) - let emailMatch = emailRegex?.firstMatch(in: htmlString, range: NSRange(htmlString.startIndex..., in: htmlString)) - let email = emailMatch.flatMap { match in - Range(match.range(at: 1), in: htmlString) - }.map { range in - String(htmlString[range]) - } - - - let pattern = "
      \\s*(\\d{2}\\s+\\d{2}\\s+\\d{2}\\s+\\d{2}\\s+\\d{2})\\s*
      " - - var phoneNumber: String? = nil - // Look for the specific div and its content - if let range = htmlString.range(of: pattern, options: [.regularExpression, .caseInsensitive]) { - let match = String(htmlString[range]) - let phonePattern = "\\d{2}\\s+\\d{2}\\s+\\d{2}\\s+\\d{2}\\s+\\d{2}" - if let phoneRange = match.range(of: phonePattern, options: .regularExpression) { - phoneNumber = String(match[phoneRange]) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - } - return (name, email, phoneNumber) + return try await FederalDataService.shared.getUmpireData(idTournament: idTournament) } } diff --git a/PadelClub/ViewModel/FederalDataViewModel.swift b/PadelClub/ViewModel/FederalDataViewModel.swift index 9bc23a0..593c6c5 100644 --- a/PadelClub/ViewModel/FederalDataViewModel.swift +++ b/PadelClub/ViewModel/FederalDataViewModel.swift @@ -176,7 +176,7 @@ class FederalDataViewModel { let collector = TournamentCollector() try await clubs.filter { $0.code != nil }.concurrentForEach { club in - let newTournaments = try await NetworkFederalService.shared.getClubFederalTournaments( + let newTournaments = try await FederalDataService.shared.getClubFederalTournaments( page: 0, tournaments: [], club: club.name, @@ -186,7 +186,7 @@ class FederalDataViewModel { ) // Safely add to collector - await collector.add(tournaments: newTournaments) + await collector.add(tournaments: newTournaments.tournaments) } // Get all collected tournaments diff --git a/PadelClub/Views/Club/ClubSearchView.swift b/PadelClub/Views/Club/ClubSearchView.swift index 72c880b..6c54e3a 100644 --- a/PadelClub/Views/Club/ClubSearchView.swift +++ b/PadelClub/Views/Club/ClubSearchView.swift @@ -414,18 +414,25 @@ struct FederalClubResponse: Codable { } } -enum Pratique: String, Codable { - case beach = "BEACH" - case padel = "PADEL" - case tennis = "TENNIS" - case pickle = "PICKLE" -} +//enum Pratique: String, Codable { +// case beach = "BEACH" +// case padel = "PADEL" +// case tennis = "TENNIS" +// case pickle = "PICKLE" +// +// // Additional cases for the combined values +// case tennisPadel = "tennis-padel" +// case tennisPicklePadel = "tennis-pickle-padel" +// case tennisPadelBeach = "tennis-padel-beach" +// case padelOnly = "padel" // lowercase padel +//} +// // MARK: - ClubMarker struct ClubMarker: Codable, Hashable, Identifiable { let nom, clubID, ville, distance: String let terrainPratiqueLibelle: String - let pratiques: [Pratique] + let pratiques: [String] let lat, lng: Double // Method to get the number of courts for a specific sport @@ -449,7 +456,7 @@ struct ClubMarker: Codable, Hashable, Identifiable { return courts } } - } else if pratiques.count == 1 && pratiques.first?.rawValue.lowercased() == sport.lowercased() { + } else if pratiques.count == 1 && pratiques.first?.lowercased() == sport.lowercased() { // Handle cases where only the number of courts is provided (e.g., "2 terrains") if let courtsNumber = trimmedComponent.split(separator: " ").first, let courts = Int(courtsNumber) { diff --git a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift index 5f9410f..3c5130b 100644 --- a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift +++ b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift @@ -187,13 +187,26 @@ struct TournamentLookUpView: View { private func _gatherNumbers() { Task { print("Doing.....") - for i in 0.. Date: Sun, 17 Aug 2025 09:03:48 +0200 Subject: [PATCH 6/9] update 2026 rules --- PadelClub.xcodeproj/project.pbxproj | 4 +- .../Coredata/ImportedPlayer+Extensions.swift | 2 +- .../Extensions/Tournament+Extensions.swift | 2 +- PadelClub/Views/Match/EditSharingView.swift | 4 +- .../Navigation/Agenda/ActivityView.swift | 1 + .../Agenda/TournamentLookUpView.swift | 44 +++++++++++++++-- PadelClub/Views/Player/PlayerDetailView.swift | 2 +- PadelClub/Views/Team/EditingTeamView.swift | 2 +- .../Views/Tournament/FileImportView.swift | 48 ++++++++++++++++++- .../Screen/InscriptionManagerView.swift | 1 + 10 files changed, 97 insertions(+), 13 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 5d34828..d5ed00b 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3145,7 +3145,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.45; + MARKETING_VERSION = 1.2.46; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3191,7 +3191,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.45; + MARKETING_VERSION = 1.2.46; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift index d65f340..f5c929d 100644 --- a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift +++ b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift @@ -132,6 +132,6 @@ extension ImportedPlayer: PlayerHolder { fileprivate extension Int { var femaleInMaleAssimilation: Int { - self + TournamentCategory.femaleInMaleAssimilationAddition(self) + self + TournamentCategory.femaleInMaleAssimilationAddition(self, seasonYear: Date.now.seasonYear()) } } diff --git a/PadelClub/Extensions/Tournament+Extensions.swift b/PadelClub/Extensions/Tournament+Extensions.swift index a928ded..068a90b 100644 --- a/PadelClub/Extensions/Tournament+Extensions.swift +++ b/PadelClub/Extensions/Tournament+Extensions.swift @@ -172,7 +172,7 @@ extension Tournament { func isPlayerRankInadequate(player: PlayerHolder) -> Bool { guard let rank = player.getRank() else { return false } - let _rank = player.male ? rank : rank + PlayerRegistration.addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0) + let _rank = player.male ? rank : rank + addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0) if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { return true } else { diff --git a/PadelClub/Views/Match/EditSharingView.swift b/PadelClub/Views/Match/EditSharingView.swift index 12a57e2..b8929f8 100644 --- a/PadelClub/Views/Match/EditSharingView.swift +++ b/PadelClub/Views/Match/EditSharingView.swift @@ -58,9 +58,9 @@ struct EditSharingView: View { messageData.append(message) guard - let labelOne = match.team(.one)?.teamLabelRanked( + let labelOne = match.team(.one)?.teamLabelRanked(displayStyle: .title, displayRank: displayRank, displayTeamName: displayTeamName), - let labelTwo = match.team(.two)?.teamLabelRanked( + let labelTwo = match.team(.two)?.teamLabelRanked(displayStyle: .title, displayRank: displayRank, displayTeamName: displayTeamName) else { return messageData.joined(separator: "\n") diff --git a/PadelClub/Views/Navigation/Agenda/ActivityView.swift b/PadelClub/Views/Navigation/Agenda/ActivityView.swift index 81c9bbf..9808f13 100644 --- a/PadelClub/Views/Navigation/Agenda/ActivityView.swift +++ b/PadelClub/Views/Navigation/Agenda/ActivityView.swift @@ -314,6 +314,7 @@ struct ActivityView: View { NavigationStack { TournamentLookUpView() .environment(federalDataViewModel) + .environment(navigation) } } .sheet(item: $newTournament) { tournament in diff --git a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift index 3c5130b..d6417e7 100644 --- a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift +++ b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift @@ -9,10 +9,12 @@ import SwiftUI import CoreLocation import CoreLocationUI import PadelClubData +import LeStorage struct TournamentLookUpView: View { @EnvironmentObject var dataStore: DataStore @Environment(FederalDataViewModel.self) var federalDataViewModel: FederalDataViewModel + @Environment(NavigationViewModel.self) var navigationViewModel: NavigationViewModel @StateObject var locationManager = LocationManager() @Environment(\.dismiss) private var dismiss @FocusState private var isFocused: Bool @@ -27,11 +29,20 @@ struct TournamentLookUpView: View { @State private var presentAlert: Bool = false @State private var confirmSearch: Bool = false @State private var locationRequested = false - + @State private var apiError: StoreError? + var tournaments: [FederalTournament] { federalDataViewModel.searchedFederalTournaments } + var presentApiError: Binding { + Binding { + apiError != nil + } set: { value in + + } + } + var showLastError: Binding { Binding { locationManager.lastError != nil @@ -70,6 +81,26 @@ struct TournamentLookUpView: View { secondaryButton: .cancel() ) } + .alert(isPresented: presentApiError, error: apiError, actions: { storeError in + switch storeError { + case .missingUsername: + Button("Créer un compte ou se connecter") { + dismiss() + navigationViewModel.selectedTab = .umpire + } + default: + Button("D'accord") { + apiError = nil + } + } + }, message: { storeError in + switch storeError { + case .missingUsername: + Text("Un compte est requis pour utiliser ce service de Padel Club, veuillez créer un compte ou vous connecter.") + default: + Text("Une erreur est survenue, veuillez réessayer plus tard.") + } + }) .alert("Attention", isPresented: $presentAlert, actions: { Button { presentAlert = false @@ -79,7 +110,10 @@ struct TournamentLookUpView: View { Task { await getNewPage() searching = false - dismiss() + + if apiError == nil { + dismiss() + } } } label: { Label("Tout voir", systemImage: "arrow.down.circle") @@ -232,7 +266,7 @@ struct TournamentLookUpView: View { searching = false if tournaments.isEmpty == false && tournaments.count < total && total >= 200 && requestedToGetAllPages == false { presentAlert = true - } else { + } else if apiError == nil { dismiss() } } @@ -294,7 +328,9 @@ struct TournamentLookUpView: View { } else { print("finished") } - + } catch let error as StoreError { + print("getNewPage", error) + apiError = error } catch { print("getNewPage", error) } diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 1633df7..3b1eaa8 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -157,7 +157,7 @@ struct PlayerDetailView: View { } } else if player.isMalePlayer() == false && tournament.tournamentCategory == .men, let rank = player.rank { Section { - let value = PlayerRegistration.addon(for: rank, manMax: maxMaleUnrankedValue, womanMax: tournament.femaleUnrankedValue ?? 0) + let value = tournament.addon(for: rank, manMax: maxMaleUnrankedValue, womanMax: tournament.femaleUnrankedValue ?? 0) LabeledContent { Text(value.formatted()) } label: { diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index d239148..6136e36 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -268,7 +268,7 @@ struct EditingTeamView: View { Text("Nom de l'équipe") } - if tournament.tournamentLevel.coachingIsAuthorized { + if tournament.coachingIsAuthorized() { CoachListView(team: team) } diff --git a/PadelClub/Views/Tournament/FileImportView.swift b/PadelClub/Views/Tournament/FileImportView.swift index 70b82c2..0295501 100644 --- a/PadelClub/Views/Tournament/FileImportView.swift +++ b/PadelClub/Views/Tournament/FileImportView.swift @@ -64,6 +64,7 @@ enum FileImportCustomField: Int, Identifiable, CaseIterable { struct FileImportView: View { @EnvironmentObject var dataStore: DataStore + @Environment(NavigationViewModel.self) var navigationViewModel: NavigationViewModel @Environment(Tournament.self) var tournament: Tournament @Environment(\.dismiss) private var dismiss @@ -84,7 +85,16 @@ struct FileImportView: View { @State private var presentFormatHelperView: Bool = false @State private var validatedTournamentIds: Set = Set() @State private var chunkMode: ChunkMode = .byParameter - + @State private var apiError: StoreError? + + var presentApiError: Binding { + Binding { + apiError != nil + } set: { value in + + } + } + enum ChunkMode: Int, Identifiable, CaseIterable { var id: Int { self.rawValue } case byParameter @@ -175,7 +185,11 @@ struct FileImportView: View { if let fileContent { do { try await _startImport(fileContent: fileContent, allTournaments: false) + } catch let error as StoreError { + Logger.error(error) + apiError = error } catch { + Logger.error(error) errorMessage = error.localizedDescription } } @@ -199,7 +213,11 @@ struct FileImportView: View { if let fileContent { do { try await _startImport(fileContent: fileContent, allTournaments: true) + } catch let error as StoreError { + Logger.error(error) + apiError = error } catch { + Logger.error(error) errorMessage = error.localizedDescription } } @@ -306,7 +324,11 @@ struct FileImportView: View { if let fileContent { do { try await _startImport(fileContent: fileContent, allTournaments: false) + } catch let error as StoreError { + Logger.error(error) + apiError = error } catch { + Logger.error(error) errorMessage = error.localizedDescription } } @@ -381,6 +403,26 @@ struct FileImportView: View { } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) + .alert(isPresented: presentApiError, error: apiError, actions: { storeError in + switch storeError { + case .missingUsername: + Button("Créer un compte ou se connecter") { + dismiss() + navigationViewModel.selectedTab = .umpire + } + default: + Button("D'accord") { + apiError = nil + } + } + }, message: { storeError in + switch storeError { + case .missingUsername: + Text("Un compte est requis pour utiliser ce service de Padel Club, veuillez créer un compte ou vous connecter.") + default: + Text("Une erreur est survenue, veuillez réessayer plus tard.") + } + }) .sheet(isPresented: $presentFormatHelperView) { NavigationStack { List { @@ -434,6 +476,9 @@ struct FileImportView: View { fileContent = try String(contentsOf: selectedFile) } selectedFile.stopAccessingSecurityScopedResource() + } catch let error as StoreError { + Logger.error(error) + apiError = error } catch { Logger.error(error) errorMessage = error.localizedDescription @@ -453,6 +498,7 @@ struct FileImportView: View { do { fileContent = try String(contentsOf: url) } catch { + Logger.error(error) errorMessage = error.localizedDescription } } diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index cde3a24..89b091d 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -285,6 +285,7 @@ struct InscriptionManagerView: View { }) { NavigationStack { FileImportView(defaultFileProvider: tournament.isAnimation() ? .custom : .frenchFederation) + .environment(navigation) } .tint(.master) } From 1ce74fb23547a27c148161622dbd6c25663e3ba4 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Sun, 17 Aug 2025 09:54:43 +0200 Subject: [PATCH 7/9] increase number of months in tenup tournament gathering 3 to 4 --- PadelClub.xcodeproj/project.pbxproj | 4 ++-- PadelClub/Views/Navigation/Agenda/ActivityView.swift | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index d5ed00b..e19e850 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3118,7 +3118,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; @@ -3166,7 +3166,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; diff --git a/PadelClub/Views/Navigation/Agenda/ActivityView.swift b/PadelClub/Views/Navigation/Agenda/ActivityView.swift index 9808f13..6a29320 100644 --- a/PadelClub/Views/Navigation/Agenda/ActivityView.swift +++ b/PadelClub/Views/Navigation/Agenda/ActivityView.swift @@ -426,7 +426,11 @@ struct ActivityView: View { Task { do { let clubs : [Club] = dataStore.user.clubsObjects() - try await federalDataViewModel.gatherTournaments(clubs: clubs.filter { $0.code != nil }, startDate: .now.startOfMonth) + try await federalDataViewModel.gatherTournaments( + clubs: clubs.filter { $0.code != nil }, + startDate: .now.startOfCurrentMonth, + endDate: .now.startOfCurrentMonth.addingMonths(4) + ) } catch { self.error = error } From aee7b85c665f41e81cd9ed54c0a82b3d2521c6fc Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 19 Aug 2025 17:36:44 +0200 Subject: [PATCH 8/9] =?UTF-8?q?ajout=20du=20min=20d'=C3=A9quipe=20pour=20h?= =?UTF-8?q?omologation=20ajout=20d'une=20option=20pour=20terminer=20les=20?= =?UTF-8?q?tournois=20ouverts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/Tournament+Extensions.swift | 2 +- .../Event/TournamentConfiguratorView.swift | 12 +++++++++++ .../Navigation/Agenda/EventListView.swift | 20 +++++++++++++++++++ .../TournamentLevelPickerView.swift | 11 ++++++++++ .../Screen/TableStructureView.swift | 10 +++++++++- 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/PadelClub/Extensions/Tournament+Extensions.swift b/PadelClub/Extensions/Tournament+Extensions.swift index 068a90b..65408aa 100644 --- a/PadelClub/Extensions/Tournament+Extensions.swift +++ b/PadelClub/Extensions/Tournament+Extensions.swift @@ -173,7 +173,7 @@ extension Tournament { 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) { + if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge, seasonYear: startDate.seasonYear()) { return true } else { return false diff --git a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift index 9f5b111..b114a88 100644 --- a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift +++ b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift @@ -55,6 +55,18 @@ struct TournamentConfigurationView: View { } label: { Text("Équipes souhaitées") } + + let minimumNumberOfTeams = tournament.minimumNumberOfTeams() + if minimumNumberOfTeams > 0 { + + LabeledContent { + Text(minimumNumberOfTeams.formatted()) + } label: { + Text("Minimum pour homologation") + } + + } + } } // diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index ad39a72..de9c649 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -333,6 +333,26 @@ struct EventListView: View { } label: { Text("Informations de contact Juge-Arbitre") } + Divider() + Menu { + Button { + Task { + await pcTournaments.concurrentForEach { tournament in + if tournament.hasEnded() == false { + tournament.endDate = Date() + } + } + + await MainActor.run { + dataStore.tournaments.addOrUpdate(contentOfs: pcTournaments) + } + } + } label: { + Text("Terminer les tournois encore ouverts") + } + } label: { + Text("Options avancées") + } } private func _nextMonths() -> [Date] { diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift index dc58592..2c21ffa 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift @@ -51,5 +51,16 @@ struct TournamentLevelPickerView: View { Text(type.localizedLabel()).tag(type) } } + + let minimumNumberOfTeams = tournament.minimumNumberOfTeams() + if minimumNumberOfTeams > 0 { + + LabeledContent { + Text(minimumNumberOfTeams.formatted()) + } label: { + Text("Minimum pour homologation") + } + + } } } diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index 8d30e92..43805d5 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -95,6 +95,12 @@ struct TableStructureView: View { } } label: { Text("Nombre d'équipes") + + let minimumNumberOfTeams = tournament.minimumNumberOfTeams() + if minimumNumberOfTeams > 0 { + Text("Minimum pour homologation : \(minimumNumberOfTeams)") + .foregroundStyle(.secondary) + } } LabeledContent { StepperView(count: $groupStageCount, minimum: 0, maximum: maxGroupStages) { @@ -106,7 +112,9 @@ struct TableStructureView: View { Text("Nombre de poules") } } footer: { - Text("Vous pourrez modifier la taille de vos poules de manière spécifique dans l'écran des poules.") + if groupStageCount > 0 { + Text("Vous pourrez modifier la taille de vos poules de manière spécifique dans l'écran des poules.") + } } if groupStageCount > 0 { From 7a08bda544e2c31ab090615b36f639d7ff68e840 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 20 Aug 2025 14:09:21 +0200 Subject: [PATCH 9/9] =?UTF-8?q?possiblit=C3=A9=20de=20lancer=20l'horaire?= =?UTF-8?q?=20intelligent=20sur=20tous=20les=20tournois=20d'un=20evenement?= =?UTF-8?q?=20en=20une=20fois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Cashier/Event/EventSettingsView.swift | 16 +++++++ PadelClub/Views/Cashier/Event/EventView.swift | 3 +- PadelClub/Views/Planning/PlanningView.swift | 43 ++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/PadelClub/Views/Cashier/Event/EventSettingsView.swift b/PadelClub/Views/Cashier/Event/EventSettingsView.swift index d75603a..76a8db2 100644 --- a/PadelClub/Views/Cashier/Event/EventSettingsView.swift +++ b/PadelClub/Views/Cashier/Event/EventSettingsView.swift @@ -18,6 +18,13 @@ struct EventSettingsView: View { @State private var eventStartDate: Date @FocusState private var focusedField: Tournament.CodingKeys? + var visibleOnPadelClub: Binding { + Binding { + event.confirmedTournaments().allSatisfy({ $0.isPrivate == false }) + } set: { _ in + } + } + func eventLinksPasteData() -> String { let tournaments = event.tournaments var link = [String]() @@ -105,6 +112,15 @@ struct EventSettingsView: View { } _saveAllTournaments() } + } + } + + Section { + Toggle(isOn: visibleOnPadelClub) { + Text("Visible sur Padel Club") + } + .onChange(of: visibleOnPadelClub.wrappedValue) { oldValue, newValue in + _saveAllTournaments() } } diff --git a/PadelClub/Views/Cashier/Event/EventView.swift b/PadelClub/Views/Cashier/Event/EventView.swift index e04154a..23caf52 100644 --- a/PadelClub/Views/Cashier/Event/EventView.swift +++ b/PadelClub/Views/Cashier/Event/EventView.swift @@ -99,8 +99,7 @@ struct EventView: View { case .club(let event): EventClubSettingsView(event: event) case .eventPlanning: - let allMatches = event.tournaments.flatMap { $0.allMatches() } - PlanningView(matches: allMatches, selectedScheduleDestination: .constant(nil)) + PlanningView(matches: [], selectedScheduleDestination: .constant(nil), event: event) .environment(\.matchViewStyle, .feedStyle) case .links: EventLinksView(event: event) diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index 33137e2..f6f7a34 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -22,9 +22,15 @@ struct PlanningView: View { let updatePlannedDatesTip = UpdatePlannedDatesTip() let allMatches: [Match] let timeSlotMoveOptionTip = TimeSlotMoveOptionTip() + let event: Event? - init(matches: [Match], selectedScheduleDestination: Binding) { - self.allMatches = matches + init(matches: [Match], selectedScheduleDestination: Binding, event: Event? = nil) { + self.event = event + if let event { + self.allMatches = event.confirmedTournaments().flatMap { $0.allMatches() } + } else { + self.allMatches = matches + } _selectedScheduleDestination = selectedScheduleDestination } @@ -132,6 +138,24 @@ struct PlanningView: View { } else { ToolbarItemGroup(placement: .topBarTrailing) { Menu { + if let event { + Menu { + Button { + event.confirmedTournaments().forEach { tournament in + tournament.removeAllDates() + } + } label: { + Text("Effacer tous les horaires") + } + Button { + _planEvent(event: event) + } label: { + Text("Planifier") + } + } label: { + Text("Planifier l'événement") + } + } if notSlots == false { CourtOptionsView(timeSlots: timeSlots, underlined: false) Toggle(isOn: $enableMove) { @@ -226,11 +250,26 @@ struct PlanningView: View { RowButtonView("Horaire intelligent") { selectedScheduleDestination = nil } + } else if let event { + RowButtonView("Planifier automatiquement") { + _planEvent(event: event) + } } } } } } + + private func _planEvent(event: Event) { + event.confirmedTournaments().forEach { tournament in + if let matchScheduler = tournament.matchScheduler() { + _ = matchScheduler.updateSchedule(tournament: tournament) + } else { + let matchScheduler = MatchScheduler(tournament: tournament.id, courtsAvailable: Set(tournament.courtsAvailable())) + _ = matchScheduler.updateSchedule(tournament: tournament) + } + } + } struct BySlotView: View { @Environment(\.filterOption) private var filterOption