diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 329d149..1aed825 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -743,6 +743,7 @@ FFB1C98B2C10255100B154A7 /* TournamentBroadcastRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1C98A2C10255100B154A7 /* TournamentBroadcastRowView.swift */; }; FFB9C8712BBADDE200A0EF4F /* Selectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB9C8702BBADDE200A0EF4F /* Selectable.swift */; }; FFB9C8752BBADDF700A0EF4F /* SeedInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB9C8742BBADDF700A0EF4F /* SeedInterval.swift */; }; + FFBA2D2D2CA2CE9E00D5BBDD /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; }; FFBF065C2BBD2657009D6715 /* GroupStageTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065B2BBD2657009D6715 /* GroupStageTeamView.swift */; }; FFBF065E2BBD8040009D6715 /* MatchListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065D2BBD8040009D6715 /* MatchListView.swift */; }; FFBF06602BBD9F6D009D6715 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065F2BBD9F6D009D6715 /* NavigationViewModel.swift */; }; @@ -2671,6 +2672,7 @@ FF4CBFF82C996C0600151637 /* TabItemModifier.swift in Sources */, FF4CBFF92C996C0600151637 /* DeferredViewModifier.swift in Sources */, FF4CBFFA2C996C0600151637 /* TournamentScheduleView.swift in Sources */, + FFBA2D2D2CA2CE9E00D5BBDD /* CodingContainer+Extensions.swift in Sources */, FF4CBFFB2C996C0600151637 /* MatchFormatStorageView.swift in Sources */, FF4CBFFC2C996C0600151637 /* UmpireView.swift in Sources */, FF4CBFFD2C996C0600151637 /* User.swift in Sources */, @@ -3137,6 +3139,7 @@ DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; + GCC_OPTIMIZATION_LEVEL = 0; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PadelClub/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Padel Club"; @@ -3155,7 +3158,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.17; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3180,6 +3183,7 @@ DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; + GCC_OPTIMIZATION_LEVEL = 0; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PadelClub/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Padel Club"; @@ -3198,7 +3202,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.12; + MARKETING_VERSION = 1.0.17; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3291,7 +3295,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 12; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; @@ -3313,7 +3317,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.10; + MARKETING_VERSION = 1.0.15; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3333,7 +3337,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 12; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; @@ -3354,7 +3358,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.10; + MARKETING_VERSION = 1.0.15; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3375,7 +3379,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; @@ -3397,7 +3401,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.14; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3417,7 +3421,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; @@ -3438,7 +3442,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.14; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift index ab7d66f..ba05ab0 100644 --- a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift +++ b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift @@ -74,7 +74,8 @@ extension ImportedPlayer: PlayerHolder { firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true } - func hitForSearch(_ searchText: String) -> Int { + func hitForSearch(_ searchText: String?) -> Int { + guard let searchText else { return 0 } var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current) trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ") trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ") @@ -122,6 +123,10 @@ extension ImportedPlayer: PlayerHolder { func getProgression() -> Int { return Int(progression) } + + func getComputedRank() -> Int? { + nil + } } fileprivate extension Int { diff --git a/PadelClub/Data/Federal/FederalTournament.swift b/PadelClub/Data/Federal/FederalTournament.swift index 63595b5..e61f36c 100644 --- a/PadelClub/Data/Federal/FederalTournament.swift +++ b/PadelClub/Data/Federal/FederalTournament.swift @@ -210,9 +210,17 @@ extension FederalTournament: FederalTournamentHolder { nomClub ?? villeEngagement ?? installation?.nom ?? "" } - func subtitleLabel() -> String { + func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String { "" } + + func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String { + build.level.localizedLevelLabel(displayStyle) + } + + func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool { + true + } } // MARK: - CategorieAge diff --git a/PadelClub/Data/Federal/FederalTournamentHolder.swift b/PadelClub/Data/Federal/FederalTournamentHolder.swift index b2e3890..4ee6bd6 100644 --- a/PadelClub/Data/Federal/FederalTournamentHolder.swift +++ b/PadelClub/Data/Federal/FederalTournamentHolder.swift @@ -14,9 +14,11 @@ protocol FederalTournamentHolder { var codeClub: String? { get } var tournaments: [any TournamentBuildHolder] { get } func clubLabel() -> String - func subtitleLabel() -> String + func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String var dayDuration: Int { get } var dayPeriod: DayPeriod { get } + func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String + func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool } extension FederalTournamentHolder { diff --git a/PadelClub/Data/Federal/PlayerHolder.swift b/PadelClub/Data/Federal/PlayerHolder.swift index 07c6860..72949e9 100644 --- a/PadelClub/Data/Federal/PlayerHolder.swift +++ b/PadelClub/Data/Federal/PlayerHolder.swift @@ -27,6 +27,7 @@ protocol PlayerHolder { func isNotFromCurrentDate() -> Bool func getBirthYear() -> Int? func getProgression() -> Int + func getComputedRank() -> Int? } extension PlayerHolder { diff --git a/PadelClub/Data/GroupStage.swift b/PadelClub/Data/GroupStage.swift index 5f61df1..b05d716 100644 --- a/PadelClub/Data/GroupStage.swift +++ b/PadelClub/Data/GroupStage.swift @@ -24,6 +24,7 @@ final class GroupStage: ModelObject, Storable { private var format: MatchFormat? var startDate: Date? var name: String? + var step: Int = 0 var matchFormat: MatchFormat { get { @@ -34,13 +35,14 @@ final class GroupStage: ModelObject, Storable { } } - internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil) { + internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil, step: Int = 0) { self.tournament = tournament self.index = index self.size = size self.format = matchFormat self.startDate = startDate self.name = name + self.step = step } var tournamentStore: TournamentStore { @@ -50,7 +52,7 @@ final class GroupStage: ModelObject, Storable { // MARK: - Computed dependencies func _matches() -> [Match] { - return self.tournamentStore.matches.filter { $0.groupStage == self.id } + return self.tournamentStore.matches.filter { $0.groupStage == self.id }.sorted(by: \.index) // Store.main.filter { $0.groupStage == self.id } } @@ -61,19 +63,34 @@ final class GroupStage: ModelObject, Storable { // MARK: - func teamAt(groupStagePosition: Int) -> TeamRegistration? { - teams().first(where: { $0.groupStagePosition == groupStagePosition }) + if step > 0 { + return teams().first(where: { $0.groupStagePositionAtStep(step) == groupStagePosition }) + } + return teams().first(where: { $0.groupStagePosition == groupStagePosition }) } func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { if let name { return name } + + var stepLabel = "" + if step > 0 { + stepLabel = " (" + (step + 1).ordinalFormatted(feminine: true) + " phase)" + } + switch displayStyle { - case .wide, .title: + case .title: + return "Poule \(index + 1)" + stepLabel + case .wide: return "Poule \(index + 1)" case .short: return "#\(index + 1)" } } + var computedOrder: Int { + index + step * 100 + } + func isRunning() -> Bool { // at least a match has started _matches().anySatisfy({ $0.isRunning() }) } @@ -145,6 +162,19 @@ final class GroupStage: ModelObject, Storable { } catch { Logger.error(error) } + + let groupStagesAreOverAtFirstStep = tournament.groupStagesAreOver(atStep: 0) + let nextStepGroupStages = tournament.groupStages(atStep: 1) + let groupStagesAreOverAtSecondStep = tournament.groupStagesAreOver(atStep: 1) + + if groupStagesAreOverAtFirstStep, nextStepGroupStages.isEmpty || groupStagesAreOverAtSecondStep == true, tournament.groupStageLoserBracketAreOver(), tournament.rounds().isEmpty { + tournament.endDate = Date() + do { + try DataStore.shared.tournaments.addOrUpdate(instance: tournament) + } catch { + Logger.error(error) + } + } } } @@ -190,7 +220,7 @@ final class GroupStage: ModelObject, Storable { } func initialStartDate(forTeam team: TeamRegistration) -> Date? { - guard let groupStagePosition = team.groupStagePosition else { return nil } + guard let groupStagePosition = team.groupStagePositionAtStep(step) else { return nil } return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate } @@ -257,16 +287,20 @@ final class GroupStage: ModelObject, Storable { case 4: return [2, 3, 1, 4, 5, 0] case 5: - return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9] -// return [3, 5, 8, 2, 6, 7, 1, 9, 4, 0] +// return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9] + return [3, 5, 8, 2, 6, 1, 9, 4, 7, 0] case 6: - return [1, 7, 13, 11, 3, 6, 10, 2, 8, 12, 5, 4, 9, 14, 0] - //return [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0] + //return [1, 7, 13, 11, 3, 6, 10, 2, 8, 12, 5, 4, 9, 14, 0] + return [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0] default: return [] } } + func indexOf(_ matchIndex: Int) -> Int { + _matchOrder().firstIndex(of: matchIndex) ?? matchIndex + } + private func _matchUp(for matchIndex: Int) -> [Int] { Array((0.. String { let matchUp = _matchUp(for: matchIndex) if let index = matchUp.first, let index2 = matchUp.last { - return "#\(index + 1) contre #\(index2 + 1)" + return "#\(index + 1) vs #\(index2 + 1)" } else { return "--" } @@ -326,23 +360,26 @@ final class GroupStage: ModelObject, Storable { } func unsortedTeams() -> [TeamRegistration] { + if step > 0 { + return self.tournamentStore.groupStages.filter({ $0.step == step - 1 }).compactMap({ $0.teams(true)[safe: index] }) + } return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil } } func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] { if sortedByScore { return unsortedTeams().compactMap({ team in - scores?.first(where: { $0.team.id == team.id }) ?? _score(forGroupStagePosition: team.groupStagePosition!) + scores?.first(where: { $0.team.id == team.id }) ?? _score(forGroupStagePosition: team.groupStagePositionAtStep(step)!) }).sorted { (lhs, rhs) in let predicates: [TeamScoreAreInIncreasingOrder] = [ { $0.wins < $1.wins }, { $0.setDifference < $1.setDifference }, { $0.gameDifference < $1.gameDifference}, { self._headToHead($0.team, $1.team) }, - { $0.team.groupStagePosition! > $1.team.groupStagePosition! } + { [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! } ] - for predicate in predicates { + for predicate in predicates { if !predicate(lhs, rhs) && !predicate(rhs, lhs) { continue } @@ -397,6 +434,19 @@ final class GroupStage: ModelObject, Storable { self.tournamentStore.matches.deleteDependencies(matches) } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: ._id) + tournament = try container.decode(String.self, forKey: ._tournament) + index = try container.decode(Int.self, forKey: ._index) + size = try container.decode(Int.self, forKey: ._size) + format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) + startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) + name = try container.decodeIfPresent(String.self, forKey: ._name) + step = try container.decodeIfPresent(Int.self, forKey: ._step) ?? 0 + } + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -407,7 +457,7 @@ final class GroupStage: ModelObject, Storable { try container.encode(format, forKey: ._format) try container.encode(startDate, forKey: ._startDate) try container.encode(name, forKey: ._name) - + try container.encode(step, forKey: ._step) } func insertOnServer() { @@ -428,6 +478,7 @@ extension GroupStage { case _format = "format" case _startDate = "startDate" case _name = "name" + case _step = "step" } } diff --git a/PadelClub/Data/Match.swift b/PadelClub/Data/Match.swift index 2dbfe74..70839ef 100644 --- a/PadelClub/Data/Match.swift +++ b/PadelClub/Data/Match.swift @@ -408,7 +408,7 @@ defer { } func next() -> Match? { - let matches: [Match] = self.tournamentStore.matches.filter { $0.round == round && $0.index > index } + let matches: [Match] = self.tournamentStore.matches.filter { $0.round == round && $0.index > index && $0.disabled == false } return matches.sorted(by: \.index).first } @@ -435,6 +435,10 @@ defer { else { return nil } } + func roundAndMatchTitle() -> String { + [roundTitle(), matchTitle()].compactMap({ $0 }).joined(separator: " ") + } + func topPreviousRoundMatchIndex() -> Int { return index * 2 + 1 } @@ -470,8 +474,11 @@ defer { } var computedOrder: Int { + if let groupStageObject { + return (groupStageObject.index + 1) * 100 + groupStageObject.indexOf(index) + } guard let roundObject else { return index } - return roundObject.isLoserBracket() ? roundObject.index * 100 + indexInRound() : roundObject.index * 1000 + indexInRound() + return roundObject.isLoserBracket() ? (roundObject.index + 1) * 1000 + indexInRound() : (roundObject.index + 1) * 10000 + indexInRound() } func previousMatches() -> [Match] { @@ -510,6 +517,7 @@ defer { losingTeamId = teamScoreWalkout.teamRegistration groupStageObject?.updateGroupStageState() roundObject?.updateTournamentState() + currentTournament()?.updateTournamentState() updateFollowingMatchTeamScore() } @@ -535,6 +543,7 @@ defer { groupStageObject?.updateGroupStageState() roundObject?.updateTournamentState() + currentTournament()?.updateTournamentState() updateFollowingMatchTeamScore() } diff --git a/PadelClub/Data/MatchScheduler.swift b/PadelClub/Data/MatchScheduler.swift index 6c71fe4..2ef29f9 100644 --- a/PadelClub/Data/MatchScheduler.swift +++ b/PadelClub/Data/MatchScheduler.swift @@ -93,9 +93,9 @@ final class MatchScheduler : ModelObject, Storable { } @discardableResult - func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil) -> Date { + func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date { let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue() - var groupStages: [GroupStage] = tournament.groupStages() + var groupStages: [GroupStage] = tournament.groupStages(atStep: step) if let specificGroupStage { groupStages = [specificGroupStage] } @@ -108,7 +108,7 @@ final class MatchScheduler : ModelObject, Storable { $0.confirmed = false }) - var lastDate : Date = tournament.startDate + var lastDate : Date = startDate ?? tournament.startDate let times = Set(groupStages.compactMap { $0.startDate }).sorted() if let first = times.first { @@ -122,8 +122,10 @@ final class MatchScheduler : ModelObject, Storable { } } - times.forEach({ time in - lastDate = time + times.forEach({ time in + if lastDate.isEarlierThan(time) { + lastDate = time + } let groups = groupStages.filter({ $0.startDate == time }) let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) @@ -179,30 +181,39 @@ final class MatchScheduler : ModelObject, Storable { // Get the maximum count of matches in any group let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 - // Use zip and flatMap to flatten matches in the desired order + // Flatten matches in a round-robin order by cycling through each group let flattenedMatches = (0.. 0 { @@ -216,28 +227,42 @@ final class MatchScheduler : ModelObject, Storable { } (0..= numberOfCourtsAvailablePerRotation - courtsUnavailable.count { + + if courtsUnavailable.contains(courtIndex) { + print("Court \(courtIndex) is unavailable at \(rotationStartDate)") + return false + } + + let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamId($0) }) + if !teamsAvailable { + print("Teams from match \(match.roundAndMatchTitle()) are already scheduled in this rotation") return false - } else { - return teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true } + + print("Match \(match.roundAndMatchTitle()) is available for court \(courtIndex) at \(rotationStartDate)") + return true }) { let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index) + + print("Scheduled match: \(first.roundAndMatchTitle()) on court \(courtIndex) at rotation \(rotationIndex)") + slots.append(timeMatch) teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds()) rotationMatches.removeAll(where: { $0.id == first.id }) - availableMatchs.removeAll(where: { $0.id == first.id }) + availableMatches.removeAll(where: { $0.id == first.id }) + if let index = first.groupStageObject?.index { groupLastRotation[index] = rotationIndex } } else { + print("No available matches for court \(courtIndex) in rotation \(rotationIndex), adding to free court list") freeCourtPerRotation[rotationIndex]!.append(courtIndex) } } @@ -245,6 +270,9 @@ final class MatchScheduler : ModelObject, Storable { rotationIndex += 1 } + print("All matches scheduled. Total rotations: \(rotationIndex)") + + // Organize slots and ensure courts are randomized or sorted var organizedSlots = [GroupStageTimeMatch]() for i in 0.. Int { if loserBracket { @@ -270,70 +303,96 @@ final class MatchScheduler : ModelObject, Storable { } func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool { - print(roundObject.roundTitle(), match.matchTitle()) - + print("Evaluating match: \(match.roundAndMatchTitle()) in round: \(roundObject.roundTitle()) with index: \(match.index)") + if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate { - print("can't start \(targetedStartDate) earlier than \(roundStartDate)") + print("Cannot start at \(targetedStartDate), earlier than round start date \(roundStartDate)") if targetedStartDate == minimumTargetedEndDate { + print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)") minimumTargetedEndDate = roundStartDate } else { + print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)") minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) } + print("Returning false: Match cannot start earlier than the round start date.") return false } let previousMatches = roundObject.precedentMatches(ofMatch: match) - if previousMatches.isEmpty { return true } + if previousMatches.isEmpty { + print("No ancestors matches for this match, returning true. (eg beginning of tournament 1st bracket") + return true + } - let previousMatchSlots = slots.filter({ slot in - previousMatches.map { $0.id }.contains(slot.matchID) - }) + let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) } if previousMatchSlots.isEmpty { - if previousMatches.filter({ $0.disabled == false }).allSatisfy({ $0.startDate != nil }) { + if previousMatches.filter({ !$0.disabled }).allSatisfy({ $0.startDate != nil }) { + print("All previous matches have start dates, returning true.") return true } + print("Some previous matches are pending, returning false.") return false } - if previousMatches.filter({ $0.disabled == false }).count > previousMatchSlots.count { - if previousMatches.filter({ $0.disabled == false }).anySatisfy({ $0.startDate != nil }) { + if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count { + if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) { + print("Some previous matches started, returning true.") return true } + print("Not enough previous matches have started, returning false.") return false } - + var includeBreakTime = false - if accountLoserBracketBreakTime && roundObject.isLoserBracket() { includeBreakTime = true + print("Including break time for loser bracket.") } - - if accountUpperBracketBreakTime && roundObject.isLoserBracket() == false { + + if accountUpperBracketBreakTime && !roundObject.isLoserBracket() { includeBreakTime = true + print("Including break time for upper bracket.") } - let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex }) - - guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else { + let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy { + $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex + } + + if previousMatchIsInPreviousRotation { + print("All previous matches are from earlier rotations, returning true.") + } else { + print("Some previous matches are from the current rotation.") + } + + guard let minimumPossibleEndDate = previousMatchSlots.map({ + $0.estimatedEndDate(includeBreakTime: includeBreakTime) + }).max() else { + print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).") return previousMatchIsInPreviousRotation } if targetedStartDate >= minimumPossibleEndDate { if rotationDifferenceIsImportant { + print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).") return previousMatchIsInPreviousRotation } else { + print("Targeted start date is after the minimum possible end date, returning true.") return true } } else { if targetedStartDate == minimumTargetedEndDate { + print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)") minimumTargetedEndDate = minimumPossibleEndDate } else { + print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)") minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) } + print("Targeted start date is before the minimum possible end date, returning false.") return false } } + func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? { slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min() @@ -369,13 +428,15 @@ final class MatchScheduler : ModelObject, Storable { } func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { - var slots = [TimeMatch]() var _startDate: Date? var rotationIndex = 0 var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) let courtsUnavailability = courtsUnavailability var issueFound: Bool = false + + // Log start of the function + print("Starting roundDispatcher with \(availableMatchs.count) matches and \(numberOfCourtsAvailablePerRotation) courts available") flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in if _startDate == nil { @@ -388,24 +449,21 @@ final class MatchScheduler : ModelObject, Storable { let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime) slots.append(timeMatch) } - - if slots.isEmpty == false { + + if !slots.isEmpty { rotationIndex += 1 } var freeCourtPerRotation = [Int: [Int]]() - let availableCourt = numberOfCourtsAvailablePerRotation - var courts = initialCourts ?? (0.. 0 - while availableMatchs.count > 0 && issueFound == false { + while !availableMatchs.isEmpty && !issueFound && rotationIndex < 100 { freeCourtPerRotation[rotationIndex] = [] let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 }) var rotationStartDate: Date = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate - + if shouldStartAtDispatcherDate { rotationStartDate = dispatcherStartDate shouldStartAtDispatcherDate = false @@ -413,23 +471,28 @@ final class MatchScheduler : ModelObject, Storable { courts = rotationIndex == 0 ? courts : (0.. 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], freeCourtPreviousRotation.count > 0 { - print("scenario where we are waiting for a breaktime to be over without any match to play in between or a free court was available and we need to recheck breaktime left on it") - let previousPreviousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }) + // Log courts availability and start date + print("Courts available at rotation \(rotationIndex): \(courts)") + print("Rotation start date: \(rotationStartDate)") + + // Check for court availability and break time conflicts + if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty { + print("Handling break time conflicts or waiting for free courts") + let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) } let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime) let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) - let noBreakAlreadyTested = previousRotationSlots.anySatisfy({ $0.startDate == previousEndDateNoBreak }) + let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak } + if let previousEndDate, let previousEndDateNoBreak { let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate) let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak) - print("difference w break", differenceWithBreak) - print("difference w/o break", differenceWithoutBreak) + print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)") + let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60) var difference = differenceWithBreak + if differenceWithBreak <= 0 { difference = differenceWithoutBreak } else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds { @@ -437,148 +500,127 @@ final class MatchScheduler : ModelObject, Storable { } if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate { - courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index) - }) + courts.removeAll(where: { freeCourtPreviousRotation.contains($0) }) freeCourtPerRotation[rotationIndex] = courts courts = freeCourtPreviousRotation rotationStartDate = rotationStartDate.addingTimeInterval(-difference) } } - } else if let first = availableMatchs.first { - let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration) + } else if let firstMatch = availableMatchs.first { + let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration) let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) if courtsUnavailable.count == numberOfCourtsAvailablePerRotation { - print("issue") + print("Issue: All courts unavailable in this rotation") issueFound = true } else { courts = Array(Set(courts).subtracting(Set(courtsUnavailable))) } } + // Dispatch courts and schedule matches dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) rotationIndex += 1 } - + + // Organize matches in slots var organizedSlots = [TimeMatch]() for i in 0..= availableCourts - courtsUnavailable.count { + let duration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration) + + let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) + + if courtsUnavailable.contains(courtPosition) { + print("Returning false: Court \(courtIndex) unavailable due to schedule conflicts during \(rotationStartDate).") return false } let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) - let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0 + if !canBePlayed { + print("Returning false: Match \(match.roundAndMatchTitle()) can't be played due to constraints.") + return false + } + let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0 let roundMatchesCount = roundObject.playedMatches().count - - if shouldHandleUpperRoundSlice { - print("shouldHandleUpperRoundSlice \(roundMatchesCount)") - if roundObject.parent == nil && roundMatchesCount > courts.count { - print("roundMatchesCount \(roundMatchesCount) > \(courts.count)") - if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { - print("return false, \(currentRotationSameRoundMatches) >= \(min(roundMatchesCount / 2, courts.count))") - return false - } - } - } - //if all is ok, we do a final check to see if the first - let indexInRound = match.indexInRound() - - print("Upper Round, index > 0, first Match of round \(indexInRound) and more than one court available; looking for next match (same round) \(indexInRound + 1)") - if roundObject.parent == nil && roundObject.index > 0, indexInRound == 0, let nextMatch = match.next() { - guard courtPosition < courts.count - 1, courts.count > 1 else { - print("next match and this match can not be played at the same time, returning false") + if shouldHandleUpperRoundSlice { + if roundObject.parent == nil && roundMatchesCount > courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { + print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).") return false } - if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { - - print("next match and this match can be played, returning true") - - return true - } } - - //not adding a last match of a 4-match round (final not included obviously) - print("\(currentRotationSameRoundMatches) modulo \(currentRotationSameRoundMatches%2) same round match is even, index of round is not 0 and upper bracket. If it's not the last court available \(courtIndex) == \(courts.count - 1)") + let indexInRound = match.indexInRound() - if shouldTryToFillUpCourtsAvailable == false { - if roundMatchesCount <= 4 && currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.parent == nil && ((courts.count > 1 && courtPosition >= courts.count - 1) || courts.count == 1 && availableCourts > 1) { - print("we return false") + if roundObject.parent == nil && roundObject.index > 0 && indexInRound == 0, let nextMatch = match.next() { + if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { + print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).") + return true + } else { + print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).") return false } } - + print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).") return canBePlayed }) { - print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate) + print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)") + + matchPerRound[firstMatch.roundObject!.id, default: 0] += 1 + + let timeMatch = TimeMatch( + matchID: firstMatch.id, + rotationIndex: rotationIndex, + courtIndex: courtIndex, + startDate: rotationStartDate, + durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration), + minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime + ) - if first.roundObject!.parent == nil { - if let roundIndex = matchPerRound[first.roundObject!.id] { - matchPerRound[first.roundObject!.id] = roundIndex + 1 - } else { - matchPerRound[first.roundObject!.id] = 1 - } - } - let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: first.matchFormat.breakTime.breakTime) slots.append(timeMatch) - availableMatchs.removeAll(where: { $0.id == first.id }) + availableMatchs.removeAll(where: { $0.id == firstMatch.id }) } else { - freeCourtPerRotation[rotationIndex]!.append(courtIndex) + print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.") + freeCourtPerRotation[rotationIndex]?.append(courtIndex) } + } - - if freeCourtPerRotation[rotationIndex]!.count == availableCourts { - print("no match found to be put in this rotation, check if we can put anything to another date") - freeCourtPerRotation[rotationIndex] = [] - let courtsUsed = getNextEarliestAvailableDate(from: slots) - var freeCourts: [Int] = [] - if courtsUsed.isEmpty { - freeCourts = (0.. Bool { let upperRounds: [Round] = tournament.rounds() @@ -586,16 +628,20 @@ final class MatchScheduler : ModelObject, Storable { var rounds = [Round]() + if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() { + rounds.append(groupStageLoserBracketRound) + } + if shouldEndRoundBeforeStartingNext { - rounds = upperRounds.flatMap { + rounds.append(contentsOf: upperRounds.flatMap { [$0] + $0.loserRoundsAndChildren() - } + }) } else { - rounds = upperRounds.map { + rounds.append(contentsOf: upperRounds.map { $0 } + upperRounds.flatMap { $0.loserRoundsAndChildren() - } + }) } let flattenedMatches = rounds.flatMap { round in @@ -699,6 +745,9 @@ final class MatchScheduler : ModelObject, Storable { if tournament.groupStageCount > 0 { lastDate = updateGroupStageSchedule(tournament: tournament) } + if tournament.groupStages(atStep: 1).isEmpty == false { + lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate) + } return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) } } diff --git a/PadelClub/Data/PlayerRegistration.swift b/PadelClub/Data/PlayerRegistration.swift index 534e953..41b920f 100644 --- a/PadelClub/Data/PlayerRegistration.swift +++ b/PadelClub/Data/PlayerRegistration.swift @@ -73,6 +73,7 @@ final class PlayerRegistration: ModelObject, Storable { self.ligueName = importedPlayer.ligueName self.assimilation = importedPlayer.assimilation self.source = .frenchFederation + self.birthdate = importedPlayer.birthYear } internal init?(federalData: [String], sex: Int, sexUnknown: Bool) { @@ -123,19 +124,17 @@ final class PlayerRegistration: ModelObject, Storable { var computedAge: Int? { if let birthdate { let components = birthdate.components(separatedBy: "/") - if components.count == 3 { - if let age = components.last, let ageInt = Int(age) { - let year = Calendar.current.getSportAge() - - if age.count == 2 { //si l'année est sur 2 chiffres dans le fichier - if ageInt < 23 { - return year - 2000 - ageInt - } else { - return year - 2000 + 100 - ageInt - } - } else { //si l'année est représenté sur 4 chiffres - return year - ageInt + if let age = components.last, let ageInt = Int(age) { + let year = Calendar.current.getSportAge() + + if age.count == 2 { //si l'année est sur 2 chiffres dans le fichier + if ageInt < 23 { + return year - 2000 - ageInt + } else { + return year - 2000 + 100 - ageInt } + } else { //si l'année est représenté sur 4 chiffres + return year - ageInt } } } @@ -220,11 +219,7 @@ final class PlayerRegistration: ModelObject, Storable { return "non classé" + (isMalePlayer() ? "" : "e") } } - - func getRank() -> Int { - computedRank - } - + @MainActor func updateRank(from sources: [CSVParser], lastRank: Int) async throws { if let dataFound = try await history(from: sources) { @@ -283,6 +278,11 @@ final class PlayerRegistration: ModelObject, Storable { } func setComputedRank(in tournament: Tournament) { + if tournament.isAnimation() { + computedRank = rank ?? 0 + return + } + let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 70_000 switch tournament.tournamentCategory { case .men: @@ -509,4 +509,8 @@ extension PlayerRegistration: PlayerHolder { func getProgression() -> Int { 0 } + + func getComputedRank() -> Int? { + computedRank + } } diff --git a/PadelClub/Data/Round.swift b/PadelClub/Data/Round.swift index 37ac186..c618811 100644 --- a/PadelClub/Data/Round.swift +++ b/PadelClub/Data/Round.swift @@ -598,9 +598,6 @@ defer { func deleteLoserBracket() { do { let loserRounds = loserRounds() - for loserRound in loserRounds { - try loserRound.deleteDependencies() - } try self.tournamentStore.rounds.delete(contentOfs: loserRounds) } catch { Logger.error(error) diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index aa52420..aa4f940 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -208,6 +208,7 @@ final class TeamRegistration: ModelObject, Storable { } func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String { + if let name { return name } return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ") } @@ -241,6 +242,7 @@ final class TeamRegistration: ModelObject, Storable { let arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion }) let ids : Set = Set(arrayOfIds.sorted()) let searchedIds = Set(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted()) + if ids.isEmpty || searchedIds.isEmpty { return false } return ids.hashValue == searchedIds.hashValue } @@ -511,6 +513,16 @@ final class TeamRegistration: ModelObject, Storable { return Store.main.findById(tournament) } + func groupStagePositionAtStep(_ step: Int) -> Int? { + guard let groupStagePosition else { return nil } + if step == 0 { + return groupStagePosition + } else if let groupStageObject = groupStageObject(), groupStageObject.hasEnded() { + return groupStageObject.index + } + return nil + } + enum CodingKeys: String, CodingKey { case _id = "id" case _tournament = "tournament" diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 036241d..b9bd996 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -350,13 +350,13 @@ final class Tournament : ModelObject, Storable { return Array(self.tournamentStore.teamRegistrations) } - func groupStages() -> [GroupStage] { - let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id } + func groupStages(atStep step: Int = 0) -> [GroupStage] { + let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id && $0.step == step } return groupStages.sorted(by: \.index) } func allGroupStages() -> [GroupStage] { - return Array(self.tournamentStore.groupStages) + return self.tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder) } func allRounds() -> [Round] { @@ -737,8 +737,8 @@ defer { closedRegistrationDate != nil } - func getActiveGroupStage() -> GroupStage? { - let groupStages = groupStages() + func getActiveGroupStage(atStep step: Int = 0) -> GroupStage? { + let groupStages = groupStages(atStep: step) return groupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).first ?? groupStages.first } @@ -829,7 +829,7 @@ defer { let wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending) let groupStageSpots: Int = self.groupStageSpots() - var bracketSeeds: Int = min(teamCount, _completeTeams.count) - groupStageSpots - wcBracket.count + var bracketSeeds: Int = min(teamCount, _completeTeams.count) - groupStageSpots var groupStageTeamCount: Int = groupStageSpots - wcGroupStage.count if groupStageTeamCount < 0 { groupStageTeamCount = 0 } if bracketSeeds < 0 { bracketSeeds = 0 } @@ -979,15 +979,39 @@ defer { return [] } return players.filter { player in - if player.rank == nil { return false } - if player.computedRank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { - return true - } else { - return false - } + return isPlayerRankInadequate(player: player) + } + } + + 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) + if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { + return true + } else { + return false + } + } + + func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { + if startDate.isInCurrentYear() == false { + return [] + } + return players.filter { player in + return isPlayerAgeInadequate(player: player) } } + func isPlayerAgeInadequate(player: PlayerHolder) -> Bool { + guard let computedAge = player.computedAge else { return false } + if federalTournamentAge.isAgeValid(age: computedAge) == false { + return true + } else { + return false + } + } + + func mandatoryRegistrationCloseDate() -> Date? { switch tournamentLevel { case .p500, .p1000, .p1500, .p2000: @@ -1037,6 +1061,11 @@ defer { registrationDate = previousTeamRegistrationDate } let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name) + if isAnimation() { + if newTeam.weight == 0 { + newTeam.weight = team.index(in: teams) ?? 0 + } + } teamsToImport.append(newTeam) } } @@ -1088,8 +1117,8 @@ defer { return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame } - func groupStagesMatches() -> [Match] { - return self.tournamentStore.matches.filter { $0.groupStage != nil } + func groupStagesMatches(atStep step: Int = 0) -> [Match] { + return groupStages(atStep: step).flatMap({ $0._matches() }) // return Store.main.filter(isIncluded: { $0.groupStage != nil && groupStageIds.contains($0.groupStage!) }) } @@ -1161,111 +1190,153 @@ defer { var teams: [Int: [String]] = [:] var ids: Set = Set() let rounds = rounds() - let final = rounds.last?.playedMatches().last - if let winner = final?.winningTeamId { - teams[1] = [winner] - ids.insert(winner) - } - if let finalist = final?.losingTeamId { - teams[2] = [finalist] - ids.insert(finalist) - } - - let others: [Round] = rounds.flatMap { round in - let losers = round.losers() - let minimumFinalPosition = round.seedInterval()?.last ?? teamCount - if teams[minimumFinalPosition] == nil { - teams[minimumFinalPosition] = losers.map { $0.id } - } else { - teams[minimumFinalPosition]?.append(contentsOf: losers.map { $0.id }) + let lastStep = lastStep() + if rounds.isEmpty, lastStep > 0 { + let groupStages = groupStages(atStep: lastStep) + + for groupStage in groupStages { + let groupStageTeams = groupStage.teams(true) + for teamIndex in 0.. [Int: [TeamRegistration]] { + var rankings: [Int: [TeamRegistration]] = [:] - let groupStages = groupStages() - let baseRank = teamCount - groupStageSpots() + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified - let alreadyPlaceTeams = Array(teams.values.flatMap({ $0 })) - groupStages.forEach { groupStage in - let groupStageTeams = groupStage.teams(true) - for (index, team) in groupStageTeams.enumerated() { - if team.qualified == false && alreadyPlaceTeams.contains(team.id) == false { - let groupStageWidth = max(((index == qualifiedPerGroupStage) ? groupStageCount - groupStageAdditionalQualified : groupStageCount) * (index - qualifiedPerGroupStage), 0) - - let _index = baseRank + groupStageWidth + 1 - if let existingTeams = teams[_index] { - teams[_index] = existingTeams + [team.id] - } else { - teams[_index] = [team.id] - } + finalRanks.keys.sorted().forEach { rank in + if let rankedTeamIds = finalRanks[rank] { + let teams: [TeamRegistration] = rankedTeamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) } + rankings[rank] = teams + } + } + + rankings.keys.sorted().forEach { rank in + if let rankedTeams = rankings[rank] { + rankedTeams.forEach { team in + team.finalRanking = rank + team.pointsEarned = isAnimation() ? nil : tournamentLevel.points(for: rank - 1, count: teamCount) } } } - return teams + do { + try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams()) + } catch { + Logger.error(error) + } + + + return rankings } func lockRegistration() { @@ -1357,10 +1428,14 @@ defer { } func tournamentTitle(_ displayStyle: DisplayStyle = .wide) -> String { - if tournamentLevel == .unlisted, displayStyle == .title, let name { - return name + if tournamentLevel == .unlisted, displayStyle == .title { + if let name { + return name + } else { + return tournamentLevel.localizedLevelLabel(.title) + } } - let title: String = [tournamentLevel.localizedLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle), federalTournamentAge.localizedLabel(displayStyle)].joined(separator: " ") + let title: String = [tournamentLevel.localizedLevelLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle), federalTournamentAge.localizedLabel(displayStyle)].filter({ $0.isEmpty == false }).joined(separator: " ") if displayStyle == .wide, let name { return [title, name].joined(separator: " - ") } else { @@ -1371,9 +1446,9 @@ defer { func localizedTournamentType() -> String { switch tournamentLevel { case .unlisted: - return tournamentLevel.localizedLabel(.short) + return tournamentLevel.localizedLevelLabel(.short) default: - return tournamentLevel.localizedLabel(.short) + tournamentCategory.localizedLabel(.short) + return tournamentLevel.localizedLevelLabel(.short) + tournamentCategory.localizedLabel(.short) } } @@ -1391,7 +1466,9 @@ defer { func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String { switch displayStyle { - case .wide, .title: + case .title: + startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).year()) + case .wide: startDate.formatted(date: Date.FormatStyle.DateStyle.complete, time: Date.FormatStyle.TimeStyle.omitted) case .short: startDate.formatted(date: .numeric, time: .omitted) @@ -1434,8 +1511,8 @@ defer { } } - func groupStagesAreOver() -> Bool { - let groupStages = groupStages() + func groupStagesAreOver(atStep: Int = 0) -> Bool { + let groupStages = groupStages(atStep: atStep) guard groupStages.isEmpty == false else { return true } @@ -1443,6 +1520,13 @@ defer { //return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified } + func groupStageLoserBracketAreOver() -> Bool { + guard let groupStageLoserBracket = groupStageLoserBracket() else { + return true + } + return groupStageLoserBracket.hasEnded() + } + fileprivate func _paymentMethodMessage() -> String? { return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods } @@ -1474,13 +1558,27 @@ defer { return Double(selectedPlayers.filter { $0.hasPaid() }.count) / Double(selectedPlayers.count) } + func presenceStatus() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasArrived }.count) / Double(selectedPlayers.count) + } + typealias TournamentStatus = (label:String, completion: String) func cashierStatus() async -> TournamentStatus { let selectedPlayers = selectedPlayers() - let paid = selectedPlayers.filter({ $0.hasPaid() }) + var filteredPlayers = [PlayerRegistration]() + var wording = "" + if isFree() { + wording = "présent" + filteredPlayers = selectedPlayers.filter({ $0.hasArrived }) + } else { + wording = "encaissé" + filteredPlayers = selectedPlayers.filter({ $0.hasPaid() }) + } // let label = paid.count.formatted() + " / " + selectedPlayers.count.formatted() + " joueurs encaissés" - let label = "\(paid.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs encaissés" - let completion = (Double(paid.count) / Double(selectedPlayers.count)) + let label = "\(filteredPlayers.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs \(wording)\(filteredPlayers.count.pluralSuffix)" + let completion = (Double(filteredPlayers.count) / Double(selectedPlayers.count)) let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) return TournamentStatus(label: label, completion: completionLabel) } @@ -1570,12 +1668,23 @@ defer { return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") } - func deleteAndBuildEverything() { + func deleteAndBuildEverything(preset: PadelTournamentStructurePreset = .manual) { resetBracketPosition() deleteStructure() deleteGroupStages() - buildGroupStages() - buildBracket() + + switch preset { + case .manual: + buildGroupStages() + buildBracket() + case .doubleGroupStage: + buildGroupStages() + addNewGroupStageStep() + + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + + } } func buildGroupStages() { @@ -1675,7 +1784,7 @@ defer { func deleteGroupStages() { do { - try self.tournamentStore.groupStages.delete(contentOfs: groupStages()) + try self.tournamentStore.groupStages.delete(contentOfs: allGroupStages()) } catch { Logger.error(error) } @@ -1761,6 +1870,11 @@ defer { players.forEach { player in player.teamRegistration = team.id } + if isAnimation() { + if team.weight == 0 { + team.weight = unsortedTeams().count + } + } return team } @@ -1861,6 +1975,7 @@ defer { groupStageMatchFormat = groupStageSmartMatchFormat() loserBracketMatchFormat = loserBracketSmartMatchFormat(5) matchFormat = roundSmartMatchFormat(5) + entryFee = tournamentLevel.entryFee } func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { @@ -1876,9 +1991,9 @@ defer { private func _defaultSorting() -> [MySortDescriptor] { switch teamSorting { case .rank: - [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!)] + [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.id)] case .inscriptionDate: - [.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight)] + [.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.id)] } } @@ -1888,7 +2003,7 @@ defer { && federalTournamentAge == build.age } - private let _currentSelectionSorting : [MySortDescriptor] = [.keyPath(\.weight), .keyPath(\.registrationDate!)] + private let _currentSelectionSorting : [MySortDescriptor] = [.keyPath(\.weight), .keyPath(\.registrationDate!), .keyPath(\.id)] private func _matchSchedulers() -> [MatchScheduler] { return self.tournamentStore.matchSchedulers.filter { $0.tournament == self.id } @@ -1991,6 +2106,62 @@ defer { return teamCount - teamsPerGroupStage * groupStageCount + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified + 1 } + func addNewGroupStageStep() { + let lastStep = lastStep() + 1 + for i in 0.. Int { + self.tournamentStore.groupStages.sorted(by: \.step).last?.step ?? 0 + } + + func generateSmartLoserGroupStageBracket() { + guard let groupStageLoserBracket = groupStageLoserBracket() else { return } + for i in qualifiedPerGroupStage.. [Match] { + rounds().flatMap { $0.loserRoundsAndChildren().flatMap({ $0._matches() }) } + } + // MARK: - func insertOnServer() throws { @@ -2093,6 +2264,20 @@ extension Tournament: Hashable { } extension Tournament: FederalTournamentHolder { + + func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String { + if isAnimation() { + if let name { + return name.trunc(length: DeviceHelper.charLength()) + } else if build.age == .unlisted, build.category == .unlisted { + return build.level.localizedLevelLabel(.title) + } else { + return build.level.localizedLevelLabel(displayStyle) + } + } + return build.level.localizedLevelLabel(displayStyle) + } + var codeClub: String? { club()?.code } @@ -2103,8 +2288,18 @@ extension Tournament: FederalTournamentHolder { locationLabel() } - func subtitleLabel() -> String { - subtitle() + func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String { + if isAnimation() { + if displayAgeAndCategory(forBuild: build) == false { + return [build.category.localizedLabel(), build.age.localizedLabel()].filter({ $0.isEmpty == false }).joined(separator: " ") + } else if name != nil { + return build.level.localizedLevelLabel(.title) + } else { + return "" + } + } else { + return subtitle() + } } var tournaments: [any TournamentBuildHolder] { @@ -2122,10 +2317,23 @@ extension Tournament: FederalTournamentHolder { return .weekend } } + + func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool { + if isAnimation() { + if let name, name.count < DeviceHelper.maxCharacter() { + return true + } else if build.age == .unlisted, build.category == .unlisted { + return true + } else { + return DeviceHelper.isBigScreen() + } + } + return true + } } extension Tournament: TournamentBuildHolder { - func buildHolderTitle() -> String { + func buildHolderTitle(_ displayStyle: DisplayStyle) -> String { tournamentTitle(.short) } diff --git a/PadelClub/Extensions/Calendar+Extensions.swift b/PadelClub/Extensions/Calendar+Extensions.swift index 47971b5..bc7861a 100644 --- a/PadelClub/Extensions/Calendar+Extensions.swift +++ b/PadelClub/Extensions/Calendar+Extensions.swift @@ -30,8 +30,8 @@ extension Calendar { let currentYear = component(.year, from: currentDate) // Define the date components for 1st September and 31st December of the current year - var septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) - var decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) + let septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) + let decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) // Get the actual dates for 1st September and 31st December let septemberFirst = date(from: septemberFirstComponents)! diff --git a/PadelClub/Extensions/FixedWidthInteger+Extensions.swift b/PadelClub/Extensions/FixedWidthInteger+Extensions.swift index 0309c74..f9c0d6d 100644 --- a/PadelClub/Extensions/FixedWidthInteger+Extensions.swift +++ b/PadelClub/Extensions/FixedWidthInteger+Extensions.swift @@ -8,15 +8,15 @@ import Foundation public extension FixedWidthInteger { - func ordinalFormattedSuffix() -> String { + func ordinalFormattedSuffix(feminine: Bool = false) -> String { switch self { - case 1: return "er" + case 1: return feminine ? "ère" : "er" default: return "ème" } } - func ordinalFormatted() -> String { - return self.formatted() + self.ordinalFormattedSuffix() + func ordinalFormatted(feminine: Bool = false) -> String { + return self.formatted() + self.ordinalFormattedSuffix(feminine: feminine) } var pluralSuffix: String { diff --git a/PadelClub/Extensions/String+Extensions.swift b/PadelClub/Extensions/String+Extensions.swift index 98567e7..cce72bc 100644 --- a/PadelClub/Extensions/String+Extensions.swift +++ b/PadelClub/Extensions/String+Extensions.swift @@ -10,9 +10,14 @@ import Foundation // MARK: - Trimming and stuff extension String { func trunc(length: Int, trailing: String = "…") -> String { + if length <= 0 { return self } return (self.count > length) ? self.prefix(length) + trailing : self } + func prefixTrimmed(_ length: Int) -> String { + String(trimmed.prefix(length)) + } + var trimmed: String { replaceCharactersFromSet(characterSet: .newlines, replacementString: " ").trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/PadelClub/PadelClubApp.swift b/PadelClub/PadelClubApp.swift index befb5d3..ccf8660 100644 --- a/PadelClub/PadelClubApp.swift +++ b/PadelClub/PadelClubApp.swift @@ -100,7 +100,7 @@ print("Running in Release mode") //try? Tips.resetDatastore() try? Tips.configure([ - .displayFrequency(.immediate), + .displayFrequency(.daily), .datastoreLocation(.applicationDefault) ]) } diff --git a/PadelClub/Utils/DisplayContext.swift b/PadelClub/Utils/DisplayContext.swift index 1e99890..a5aaebe 100644 --- a/PadelClub/Utils/DisplayContext.swift +++ b/PadelClub/Utils/DisplayContext.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit enum DisplayContext { case addition @@ -27,3 +28,40 @@ enum MatchViewStyle { case plainStyle // vue detail case tournamentResultStyle //vue resultat tournoi } + +struct DeviceHelper { + static func isBigScreen() -> Bool { + switch UIDevice.current.userInterfaceIdiom { + case .pad: // iPads + return true + case .phone: // iPhones (you can add more cases here for large vs small phones) + if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc. + return true // large phones + } else { + return false // smaller phones + } + default: + return false // Other devices (Apple Watch, TV, etc.) + } + + } + + static func maxCharacter() -> Int { + switch UIDevice.current.userInterfaceIdiom { + case .pad: // iPads + return 30 + case .phone: // iPhones (you can add more cases here for large vs small phones) + if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc. + return 15 // large phones + } else { + return 9 // smaller phones + } + default: + return 9 // Other devices (Apple Watch, TV, etc.) + } + } + + static func charLength() -> Int { + isBigScreen() ? 0 : 15 + } +} diff --git a/PadelClub/Utils/FileImportManager.swift b/PadelClub/Utils/FileImportManager.swift index 7ee5ced..a275e03 100644 --- a/PadelClub/Utils/FileImportManager.swift +++ b/PadelClub/Utils/FileImportManager.swift @@ -278,9 +278,9 @@ class FileImportManager { FederalTournamentAge.allCases.first(where: { $0.importingRawValue.canonicalVersion == ageCategory.canonicalVersion }) ?? .senior } - let resultOne = Array(dataOne.dropFirst(3).dropLast()) - let resultTwo = Array(dataTwo.dropFirst(3).dropLast()) - let sexUnknown: Bool = (resultOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (resultTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) + let resultOne = Array(dataOne.dropFirst(3).dropLast(3)) + let resultTwo = Array(dataTwo.dropFirst(3).dropLast(3)) + let sexUnknown: Bool = (dataOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (dataTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) var sexPlayerOne : Int { switch tournamentCategory { @@ -434,39 +434,77 @@ class FileImportManager { let fetchRequest = ImportedPlayer.fetchRequest() let federalContext = PersistenceController.shared.localContainer.viewContext - let results: [TeamHolder] = lines.chunked(into: 2).map { team in + let results: [TeamHolder] = lines.chunked(byParameterAt: 1).map { team in var teamName: String? = nil let players = team.map { player in let data = player.components(separatedBy: separator) - let lastName : String = data[safe: 2]?.trimmed ?? "" - let firstName : String = data[safe: 3]?.trimmed ?? "" + let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? "" + let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? "" let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male if data[safe: 1]?.trimmed != nil { teamName = data[safe: 1]?.trimmed } - let phoneNumber : String? = data[safe: 4]?.trimmed - let email : String? = data[safe: 5]?.trimmed + let phoneNumber : String? = data[safe: 4]?.trimmed.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).prefixTrimmed(50) + let email : String? = data[safe: 5]?.prefixTrimmed(50) let rank : Int? = data[safe: 6]?.trimmed.toInt() - let licenceId : String? = data[safe: 7]?.trimmed - let club : String? = data[safe: 8]?.trimmed + let licenceId : String? = data[safe: 7]?.prefixTrimmed(50) + let club : String? = data[safe: 8]?.prefixTrimmed(200) let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName) fetchRequest.predicate = predicate let found = try? federalContext.fetch(fetchRequest).first if let found, autoSearch { let player = PlayerRegistration(importedPlayer: found) player.setComputedRank(in: tournament) + player.email = email + player.phoneNumber = phoneNumber return player } else { let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email) if rank == nil, autoSearch { player.setComputedRank(in: tournament) + } else { + player.computedRank = rank ?? 0 } return player } } - return TeamHolder(players: players, tournamentCategory: .men, tournamentAgeCategory: .senior, previousTeam: nil, name: teamName, tournament: tournament) + return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: nil, name: teamName, tournament: tournament) } return results } } + +extension Array where Element == String { + /// Groups the array of CSV lines based on the same value at the specified column index. + /// If no key is found, it defaults to chunking the array into groups of 2 lines. + /// - Parameter index: The index of the CSV column to group by. + /// - Returns: An array of arrays, where each inner array contains lines grouped by the CSV parameter or by default chunks of 2. + func chunked(byParameterAt index: Int) -> [[String]] { + var groups: [String: [String]] = [:] + + for line in self { + let columns = line.split(separator: ";", omittingEmptySubsequences: false).map { String($0) } + if index < columns.count { + let key = columns[index] + + if groups[key] == nil { + groups[key] = [] + } + groups[key]?.append(line) + } else { + // Handle out-of-bounds by continuing + print("Warning: Index \(index) out of bounds for line: \(line)") + } + } + + // If no valid groups found, chunk into groups of 2 lines + if groups.isEmpty { + return self.chunked(into: 2) + } else { + // Append groups by parameter value, converting groups.values into an array of arrays + return groups.map { $0.value } + } + } +} + diff --git a/PadelClub/Utils/HtmlGenerator.swift b/PadelClub/Utils/HtmlGenerator.swift index af72534..4139f67 100644 --- a/PadelClub/Utils/HtmlGenerator.swift +++ b/PadelClub/Utils/HtmlGenerator.swift @@ -186,7 +186,7 @@ class HtmlGenerator: ObservableObject { .day() .dateSeparator(.dash)) - let name = tournament.tournamentLevel.localizedLabel() + "-" + tournament.tournamentCategory.importingRawValue + let name = tournament.tournamentLevel.localizedLevelLabel() + "-" + tournament.tournamentCategory.importingRawValue return pdfFolderURL.appendingPathComponent(stringDate + "-" + name + ".pdf") } diff --git a/PadelClub/Utils/LocationManager.swift b/PadelClub/Utils/LocationManager.swift index 3961bf6..0af0e36 100644 --- a/PadelClub/Utils/LocationManager.swift +++ b/PadelClub/Utils/LocationManager.swift @@ -16,7 +16,18 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var postalCode: String? @Published var requestStarted: Bool = false @Published var userReadableCityOrZipcode: String = "" - @Published var lastError: Error? = nil + @Published var lastError: LocalizedError? = nil + + enum LocationError: LocalizedError { + case unknownError(error: Error) + + var errorDescription: String? { + switch self { + case .unknownError(let error): + return "Padel Club n'a pas réussi à vous localiser." + } + } + } override init() { super.init() @@ -49,7 +60,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("locationManager didFailWithError", error) requestStarted = false - self.lastError = error + self.lastError = LocationError.unknownError(error: error) } func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) { diff --git a/PadelClub/Utils/PadelRule.swift b/PadelClub/Utils/PadelRule.swift index 7e6f922..b313f00 100644 --- a/PadelClub/Utils/PadelRule.swift +++ b/PadelClub/Utils/PadelRule.swift @@ -30,7 +30,7 @@ protocol TournamentBuildHolder: Identifiable { var category: TournamentCategory { get } var level: TournamentLevel { get } var age: FederalTournamentAge { get } - func buildHolderTitle() -> String + func buildHolderTitle(_ displayStyle: DisplayStyle) -> String } struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable { @@ -43,36 +43,36 @@ struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable { // var japFirstName: String? = nil // var japLastName: String? = nil - func buildHolderTitle() -> String { - computedLabel + func buildHolderTitle(_ displayStyle: DisplayStyle) -> String { + computedLabel(displayStyle) } var identifier: String { - level.localizedLabel()+":"+category.localizedLabel()+":"+age.localizedLabel() + level.localizedLevelLabel()+":"+category.localizedLabel()+":"+age.localizedLabel() } - var computedLabel: String { - if age == .senior { return localizedLabel() } - return localizedLabel() + " " + localizedAge + func computedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if age == .senior { return localizedLabel(displayStyle) } + return localizedLabel(displayStyle) + " " + localizedAge(displayStyle) } func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { - level.localizedLabel() + category.localizedLabel(.short) + level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle) } - var localizedTitle: String { - level.localizedLabel() + " " + category.localizedLabel() + func localizedTitle(_ displayStyle: DisplayStyle = .wide) -> String { + level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle) } - var localizedAge: String { - age.tournamentDescriptionLabel + func localizedAge(_ displayStyle: DisplayStyle = .wide) -> String { + age.localizedLabel(displayStyle) } } extension TournamentBuild { init?(category: String, level: String, age: FederalTournamentAge = .senior) { - guard let levelFound = TournamentLevel.allCases.first(where: { $0.localizedLabel() == level }) else { return nil } + guard let levelFound = TournamentLevel.allCases.first(where: { $0.localizedLevelLabel() == level }) else { return nil } var c = category if c.hasPrefix("ME") { @@ -209,9 +209,9 @@ enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable { case .senior: return "Senior" case .a45: - return "+45 ans" + return "45 ans" case .a55: - return "+55 ans" + return "55 ans" } } @@ -276,6 +276,28 @@ enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable { var tournamentDescriptionLabel: String { return localizedLabel() } + + func isAgeValid(age: Int?) -> Bool { + guard let age else { return true } + switch self { + case .unlisted: + return true + case .a11_12: + return age < 13 + case .a13_14: + return age < 15 + case .a15_16: + return age < 17 + case .a17_18: + return age < 19 + case .senior: + return age >= 11 + case .a45: + return age >= 45 + case .a55: + return age >= 55 + } + } } enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { @@ -293,6 +315,16 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { self.init(rawValue: value) } + var entryFee: Double? { + switch self { + case .unlisted: + return nil + case .p25: + return 15 + default: + return 20 + } + } func searchRawValue() -> String { String(describing: self) } @@ -465,8 +497,14 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { } } - func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { - if self == .unlisted { return displayStyle == .title ? "Animation" : "Anim." } + func localizedLevelLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if self == .unlisted { + if DeviceHelper.isBigScreen() { + return "Animation" + } else { + return displayStyle == .title ? "Animation" : "Anim." + } + } return String(describing: self).capitalized } @@ -837,7 +875,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable { case .men: switch displayStyle { case .title: - return "DH" + return "Hommes" case .wide: return "Hommes" case .short: @@ -846,7 +884,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable { case .women: switch displayStyle { case .title: - return "DD" + return "Dames" case .wide: return "Dames" case .short: @@ -855,7 +893,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable { case .mix: switch displayStyle { case .title: - return "MX" + return "Mixte" case .wide: return "Mixte" case .short: @@ -1637,3 +1675,27 @@ enum AnimationType: Int, CaseIterable, Hashable, Identifiable { } } +enum PadelTournamentStructurePreset: Int, Identifiable, CaseIterable { + var id: Int { self.rawValue } + + case manual + case doubleGroupStage + + func localizedStructurePresetTitle() -> String { + switch self { + case .manual: + return "Défaut" + case .doubleGroupStage: + return "2 phases de poules" + } + } + + func localizedDescriptionStructurePresetTitle() -> String { + switch self { + case .manual: + return "24 équipes, 4 poules de 4, 1 qualifié par poule" + case .doubleGroupStage: + return "Poules qui enchaîne sur une autre phase de poule : les premiers de chaque se retrouve ensemble, puis les 2èmes, etc." + } + } +} diff --git a/PadelClub/Utils/Tips.swift b/PadelClub/Utils/Tips.swift index ae4c3c8..bb136dd 100644 --- a/PadelClub/Utils/Tips.swift +++ b/PadelClub/Utils/Tips.swift @@ -430,7 +430,7 @@ struct CreateAccountTip: Tip { Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte") //todo //Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus") - Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Jeter un oeil au site Padel Club") + Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Voir le site Padel Club") } enum ActionKey: String { @@ -549,6 +549,29 @@ struct TeamsExportTip: Tip { } } +struct PlayerTournamentSearchTip: Tip { + var title: Text { + Text("Cherchez un tournoi autour de vous !") + } + + var message: Text? { + Text("Padel Club facilite la recherche de tournois et l'inscription !") + } + + var image: Image? { + Image(systemName: "trophy.circle") + } + + var actions: [Action] { + Action(id: ActionKey.selectAction.rawValue, title: "Éssayer") + } + + enum ActionKey: String { + case selectAction = "selectAction" + } + +} + struct TipStyleModifier: ViewModifier { @Environment(\.colorScheme) var colorScheme var tint: Color? diff --git a/PadelClub/ViewModel/AgendaDestination.swift b/PadelClub/ViewModel/AgendaDestination.swift index abe2126..2f08364 100644 --- a/PadelClub/ViewModel/AgendaDestination.swift +++ b/PadelClub/ViewModel/AgendaDestination.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import TipKit enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { var id: Int { self.rawValue } @@ -33,6 +34,15 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { } } + func associatedTip() -> (any Tip)? { + switch self { + case .around: + return nil //PlayerTournamentSearchTip() + default: + return nil + } + } + func selectionLabel(index: Int) -> String { localizedTitleKey } diff --git a/PadelClub/ViewModel/FederalDataViewModel.swift b/PadelClub/ViewModel/FederalDataViewModel.swift index 010b211..71c0cf1 100644 --- a/PadelClub/ViewModel/FederalDataViewModel.swift +++ b/PadelClub/ViewModel/FederalDataViewModel.swift @@ -25,7 +25,7 @@ class FederalDataViewModel { func filterStatus() -> String { var labels: [String] = [] - labels.append(contentsOf: levels.map { $0.localizedLabel() }.formatList()) + labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList()) labels.append(contentsOf: categories.map { $0.localizedLabel() }.formatList()) labels.append(contentsOf: ageCategories.map { $0.localizedLabel() }.formatList()) let clubNames = selectedClubs.compactMap { codeClub in @@ -97,6 +97,33 @@ class FederalDataViewModel { }) } + func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int { + tournaments.filter({ tournament in + (selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!)) + && + (dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod)) + && + (dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration)) + }) + .flatMap { $0.tournaments } + .filter { + (levels.isEmpty || levels.contains($0.level)) + && + (categories.isEmpty || categories.contains($0.category)) + && + (ageCategories.isEmpty || ageCategories.contains($0.age)) + } + .count + } + + func buildIsValid(_ build: any TournamentBuildHolder) -> Bool { + (levels.isEmpty || levels.contains(build.level)) + && + (categories.isEmpty || categories.contains(build.category)) + && + (ageCategories.isEmpty || ageCategories.contains(build.age)) + } + func isTournamentValidForFilters(_ tournament: Tournament) -> Bool { if tournament.isDeleted { return false } let firstPart = (levels.isEmpty || levels.contains(tournament.level)) diff --git a/PadelClub/ViewModel/SearchViewModel.swift b/PadelClub/ViewModel/SearchViewModel.swift index 8d8e9e7..0b066c3 100644 --- a/PadelClub/ViewModel/SearchViewModel.swift +++ b/PadelClub/ViewModel/SearchViewModel.swift @@ -168,7 +168,7 @@ class SearchViewModel: ObservableObject, Identifiable { predicates.append(NSPredicate(format: "license contains[cd] %@", canonicalVersionWithoutPunctuation)) } predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation)) - let components = canonicalVersionWithoutPunctuation.split(separator: " ").sorted() + let components = canonicalVersionWithoutPunctuation.split(separator: " ") let pattern = components.joined(separator: ".*") let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern) predicates.append(predicate) @@ -354,7 +354,7 @@ class SearchViewModel: ObservableObject, Identifiable { orPredicates.append(contentsOf: nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) }) } - let components = text.split(separator: " ").sorted() + let components = text.split(separator: " ") let pattern = components.joined(separator: ".*") print(text, pattern) let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern) diff --git a/PadelClub/ViewModel/Selectable.swift b/PadelClub/ViewModel/Selectable.swift index 6734828..66309f9 100644 --- a/PadelClub/ViewModel/Selectable.swift +++ b/PadelClub/ViewModel/Selectable.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import TipKit protocol Selectable { func selectionLabel(index: Int) -> String @@ -15,9 +16,14 @@ protocol Selectable { func badgeValueColor() -> Color? func displayImageIfValueZero() -> Bool func systemImage() -> String? + func associatedTip() -> (any Tip)? } extension Selectable { + func associatedTip() -> (any Tip)? { + return nil + } + func systemImage() -> String? { return nil } @@ -54,3 +60,30 @@ enum Badge { } } } + +struct SelectionTipViewModifier: ViewModifier { + let selectable: Selectable + let action: () -> Void + func body(content: Content) -> some View { + if let tip = selectable.associatedTip() { + if #available(iOS 18.0, *) { + content + .popoverTip(tip, arrowEdge: .top) { _ in + action() + tip.invalidate(reason: .tipClosed) + } + } else { + content + } + } else { + content + } + } +} + +extension View { + func selectableTipViewModifier(selectable: Selectable, action: @escaping () -> Void) -> some View { + modifier(SelectionTipViewModifier(selectable: selectable, action: action)) + } +} + diff --git a/PadelClub/Views/Calling/CallMessageCustomizationView.swift b/PadelClub/Views/Calling/CallMessageCustomizationView.swift index 93a1779..569f420 100644 --- a/PadelClub/Views/Calling/CallMessageCustomizationView.swift +++ b/PadelClub/Views/Calling/CallMessageCustomizationView.swift @@ -95,6 +95,16 @@ struct CallMessageCustomizationView: View { } .headerProminence(.increased) .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .toolbarBackground(.visible, for: .navigationBar) .navigationTitle("Message de convocation") .toolbar { diff --git a/PadelClub/Views/Calling/Components/MenuWarningView.swift b/PadelClub/Views/Calling/Components/MenuWarningView.swift index 71eacb7..ef094a0 100644 --- a/PadelClub/Views/Calling/Components/MenuWarningView.swift +++ b/PadelClub/Views/Calling/Components/MenuWarningView.swift @@ -124,7 +124,7 @@ struct MenuWarningView: View { @ViewBuilder func _teamActionView(_ team: TeamRegistration) -> some View { - Menu("Toute l'équipe") { + Menu(team.name ?? "Toute l'équipe") { let players = team.players() _actionView(players: players) } diff --git a/PadelClub/Views/Cashier/CashierDetailView.swift b/PadelClub/Views/Cashier/CashierDetailView.swift index 32992c1..074a67e 100644 --- a/PadelClub/Views/Cashier/CashierDetailView.swift +++ b/PadelClub/Views/Cashier/CashierDetailView.swift @@ -89,7 +89,8 @@ struct CashierDetailView: View { let showTournamentTitle: Bool @State private var earnings: Double? = nil @State private var paidCompletion: Double? = nil - + @State private var presence: Double? = nil + var body: some View { Section { LabeledContent { @@ -99,9 +100,15 @@ struct CashierDetailView: View { ProgressView() } } label: { - Text("Encaissement") - if let paidCompletion { - Text(paidCompletion.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) + Text(tournament.isFree() ? "Présence" : "Encaissement") + if tournament.isFree() { + if let presence { + Text(presence.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) + } + } else { + if let paidCompletion { + Text(paidCompletion.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) + } } } CashierDetailDisclosureView(tournament: tournament) @@ -119,6 +126,10 @@ struct CashierDetailView: View { if paidCompletion == nil { paidCompletion = tournament.paidCompletion() } + + if presence == nil { + presence = tournament.presenceStatus() + } } } } diff --git a/PadelClub/Views/Cashier/CashierSettingsView.swift b/PadelClub/Views/Cashier/CashierSettingsView.swift index 908465f..fd51e73 100644 --- a/PadelClub/Views/Cashier/CashierSettingsView.swift +++ b/PadelClub/Views/Cashier/CashierSettingsView.swift @@ -11,36 +11,77 @@ import LeStorage struct CashierSettingsView: View { @EnvironmentObject var dataStore: DataStore + @State private var entryFee: Double? = nil + @Bindable var tournament: Tournament + @FocusState private var focusedField: Tournament.CodingKeys? + let priceTags: [Double] = [15.0, 20.0, 25.0] - var tournaments: [Tournament] - - init(tournaments: [Tournament]) { - self.tournaments = tournaments - } - init(tournament: Tournament) { - self.tournaments = [tournament] + self.tournament = tournament + _entryFee = State(wrappedValue: tournament.entryFee) } var body: some View { List { + Section { + LabeledContent { + TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.current.currency?.identifier ?? "EUR")) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._entryFee) + } label: { + Text("Inscription") + } + } footer: { + Text("Si vous souhaitez que Padel Club vous aide à suivre les encaissements, indiquer un prix d'inscription. Sinon Padel Club vous aidera à suivre simplement l'arrivée et la présence des joueurs.") + } + + Section { + RowButtonView("Tout le monde est arrivé", role: .destructive) { + let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() }) + players.forEach { player in + player.hasArrived = true + } + do { + try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) + } catch { + Logger.error(error) + } + } + } footer: { + Text("Indique tous les joueurs sont là") + } + + Section { + RowButtonView("Personne n'est là", role: .destructive) { + let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() }) + players.forEach { player in + player.hasArrived = false + } + do { + try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) + } catch { + Logger.error(error) + } + } + } footer: { + Text("Indique qu'aucun joueur n'est arrivé") + } + Section { RowButtonView("Tout le monde a réglé", role: .destructive) { - - for tournament in self.tournaments { - let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() }) - players.forEach { player in - if player.hasPaid() == false { - player.paymentType = .gift - } - } - do { - try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) - } catch { - Logger.error(error) + let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() }) + players.forEach { player in + if player.hasPaid() == false { + player.paymentType = .gift } } - + do { + try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) + } catch { + Logger.error(error) + } } } footer: { Text("Passe tous les joueurs qui n'ont pas réglé en offert") @@ -48,24 +89,72 @@ struct CashierSettingsView: View { Section { RowButtonView("Personne n'a réglé", role: .destructive) { - for tournament in self.tournaments { - let store = tournament.tournamentStore - - let players = tournament.selectedPlayers() - players.forEach { player in - player.paymentType = nil - } - do { - try store.playerRegistrations.addOrUpdate(contentOfs: players) - } catch { - Logger.error(error) - } + let store = tournament.tournamentStore + + let players = tournament.selectedPlayers() + players.forEach { player in + player.paymentType = nil + } + do { + try store.playerRegistrations.addOrUpdate(contentOfs: players) + } catch { + Logger.error(error) } } } footer: { Text("Remet à zéro le type d'encaissement de tous les joueurs") } + } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + ToolbarItem(placement: .keyboard) { + HStack { + if tournament.isFree() { + ForEach(priceTags, id: \.self) { priceTag in + Button(priceTag.formatted(.currency(code: "EUR"))) { + entryFee = priceTag + tournament.entryFee = priceTag + focusedField = nil + } + .buttonStyle(.bordered) + } + } else { + Button("Gratuit") { + entryFee = nil + tournament.entryFee = nil + focusedField = nil + } + .buttonStyle(.bordered) + + } + Spacer() + Button("Valider") { + tournament.entryFee = entryFee + focusedField = nil + } + .buttonStyle(.bordered) + } + } + } + } + .onChange(of: tournament.entryFee) { + _save() + } + } + + private func _save() { + do { + try dataStore.tournaments.addOrUpdate(instance: tournament) + } catch { + Logger.error(error) } } } diff --git a/PadelClub/Views/Cashier/CashierView.swift b/PadelClub/Views/Cashier/CashierView.swift index ce35f7f..b022259 100644 --- a/PadelClub/Views/Cashier/CashierView.swift +++ b/PadelClub/Views/Cashier/CashierView.swift @@ -57,6 +57,7 @@ class CashierViewModel: ObservableObject { let id: UUID = UUID() @Published var sortOption: SortOption = .callDate @Published var filterOption: FilterOption = .all + @Published var presenceFilterOption: PresenceFilterOption = .all @Published var sortOrder: SortOrder = .ascending @Published var searchText: String = "" @Published var isSearching: Bool = false @@ -69,9 +70,14 @@ class CashierViewModel: ObservableObject { func _shouldDisplayPlayer(_ player: PlayerRegistration) -> Bool { if searchText.isEmpty == false { - sortOption.shouldDisplayPlayer(player) && filterOption.shouldDisplayPlayer(player) && player.contains(searchText) + sortOption.shouldDisplayPlayer(player) + && filterOption.shouldDisplayPlayer(player) + && presenceFilterOption.shouldDisplayPlayer(player) + && player.contains(searchText) } else { - sortOption.shouldDisplayPlayer(player) && filterOption.shouldDisplayPlayer(player) + sortOption.shouldDisplayPlayer(player) + && filterOption.shouldDisplayPlayer(player) + && presenceFilterOption.shouldDisplayPlayer(player) } } @@ -183,6 +189,37 @@ class CashierViewModel: ObservableObject { } } + enum PresenceFilterOption: Int, Identifiable, CaseIterable { + case all + case hasArrived + case hasNotArrived + + var id: Int { self.rawValue } + + func localizedLabel() -> String { + switch self { + case .all: + return "Tous" + case .hasArrived: + return "Présent" + case .hasNotArrived: + return "Absent" + } + } + + func shouldDisplayPlayer(_ player: PlayerRegistration) -> Bool { + switch self { + case .all: + return true + case .hasArrived: + return player.hasArrived + case .hasNotArrived: + return player.hasArrived == false + + } + } + } + } struct CashierView: View { @@ -201,16 +238,42 @@ struct CashierView: View { _players = .init(wrappedValue: teams.flatMap({ $0.unsortedPlayers() })) } + private func _isFree() -> Bool { + if tournaments.count == 1 { + return tournaments.first?.isFree() == true + } else { + return false + } + } + + private func _editingOptions() -> [EditablePlayerView.PlayerEditingOption] { + if _isFree() { + return [.licenceId, .name, .presence] + } else { + return [.licenceId, .name, .payment] + } + } + var body: some View { List { if cashierViewModel.isSearching == false { Section { - Picker(selection: $cashierViewModel.filterOption) { - ForEach(CashierViewModel.FilterOption.allCases) { filterOption in + Picker(selection: $cashierViewModel.presenceFilterOption) { + ForEach(CashierViewModel.PresenceFilterOption.allCases) { filterOption in Text(filterOption.localizedLabel()).tag(filterOption) } } label: { - Text("Statut du règlement") + Text("Présence") + } + + if _isFree() == false { + Picker(selection: $cashierViewModel.filterOption) { + ForEach(CashierViewModel.FilterOption.allCases) { filterOption in + Text(filterOption.localizedLabel()).tag(filterOption) + } + } label: { + Text("Statut du règlement") + } } Picker(selection: $cashierViewModel.sortOption) { @@ -239,12 +302,12 @@ struct CashierView: View { switch cashierViewModel.sortOption { case .teamRank: - TeamRankView(teams: teams, displayTournamentTitle: tournaments.count > 1) + TeamRankView(teams: teams, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions()) case .alphabeticalLastName, .alphabeticalFirstName, .playerRank, .age: - PlayerCashierView(players: filteredPlayers, displayTournamentTitle: tournaments.count > 1) + PlayerCashierView(players: filteredPlayers, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions()) case .callDate: let _teams = teams.filter({ $0.callDate != nil }) - TeamCallDateView(teams: _teams, displayTournamentTitle: tournaments.count > 1) + TeamCallDateView(teams: _teams, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions()) } } .onAppear { @@ -279,11 +342,12 @@ struct CashierView: View { @EnvironmentObject var cashierViewModel: CashierViewModel let players: [PlayerRegistration] let displayTournamentTitle: Bool - + let editingOptions: [EditablePlayerView.PlayerEditingOption] + var body: some View { ForEach(players) { player in Section { - EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) + EditablePlayerView(player: player, editingOptions: editingOptions) } header: { if displayTournamentTitle, let tournamentTitle = player.tournament()?.tournamentTitle() { Text(tournamentTitle) @@ -301,6 +365,7 @@ struct CashierView: View { @EnvironmentObject var cashierViewModel: CashierViewModel let teams: [TeamRegistration] let displayTournamentTitle: Bool + let editingOptions: [EditablePlayerView.PlayerEditingOption] var body: some View { ForEach(teams) { team in @@ -308,11 +373,17 @@ struct CashierView: View { if players.isEmpty == false { Section { ForEach(players) { player in - EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) + EditablePlayerView(player: player, editingOptions: editingOptions) } } header: { - if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() { - Text(tournamentTitle) + HStack { + if let name = team.name { + Text(name) + } + if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() { + Spacer() + Text(tournamentTitle) + } } } footer: { if let callDate = team.callDate { @@ -329,6 +400,7 @@ struct CashierView: View { @EnvironmentObject var cashierViewModel: CashierViewModel let teams: [TeamRegistration] let displayTournamentTitle: Bool + let editingOptions: [EditablePlayerView.PlayerEditingOption] var body: some View { let groupedTeams = Dictionary(grouping: teams) { team in @@ -343,10 +415,15 @@ struct CashierView: View { if players.isEmpty == false { Section { ForEach(players) { player in - EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) + EditablePlayerView(player: player, editingOptions: editingOptions) } } header: { + if let name = team.name { + Text(name) + } + if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() { + Spacer() Text(tournamentTitle) } } footer: { diff --git a/PadelClub/Views/Cashier/Event/EventSettingsView.swift b/PadelClub/Views/Cashier/Event/EventSettingsView.swift index 58b5428..cad0f0a 100644 --- a/PadelClub/Views/Cashier/Event/EventSettingsView.swift +++ b/PadelClub/Views/Cashier/Event/EventSettingsView.swift @@ -27,7 +27,7 @@ struct EventSettingsView: View { link.append(tournaments.compactMap({ tournament in if let url = tournament.shareURL(pageLink) { var tournamentLink = [String]() - tournamentLink.append(tournament.tournamentTitle()) + tournamentLink.append(tournament.tournamentTitle(.title)) tournamentLink.append(url.absoluteString) return tournamentLink.joined(separator: "\n") } else { @@ -46,12 +46,14 @@ struct EventSettingsView: View { var body: some View { Form { Section { - TextField("Description de l'événement", text: $eventName, axis: .vertical) + TextField("Nom de l'événement", text: $eventName, axis: .vertical) .lineLimit(2) .keyboardType(.alphabet) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity) .focused($textFieldIsFocus) + } header: { + Text("Nom de l'événement") } footer: { if eventName.isEmpty == false { FooterButtonView("effacer le nom") { @@ -85,6 +87,16 @@ struct EventSettingsView: View { } } } + .navigationBarBackButtonHidden(textFieldIsFocus) + .toolbar(content: { + if textFieldIsFocus { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + textFieldIsFocus = false + } + } + } + }) .toolbar { if textFieldIsFocus { ToolbarItem(placement: .keyboard) { diff --git a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift index dfffa60..9333ee9 100644 --- a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift +++ b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift @@ -22,11 +22,12 @@ struct TournamentConfigurationView: View { var body: some View { Picker(selection: $tournament.federalLevelCategory, label: Text("Niveau")) { ForEach(TournamentLevel.allCases) { type in - Text(type.localizedLabel(.title)).tag(type) + Text(type.localizedLevelLabel(.title)).tag(type) } } .onChange(of: tournament.federalLevelCategory) { if tournament.federalLevelCategory == .unlisted { + tournament.hideTeamsWeight = true tournament.federalCategory = .unlisted tournament.federalAgeCategory = .unlisted } else { @@ -40,7 +41,7 @@ struct TournamentConfigurationView: View { } Picker(selection: $tournament.federalCategory, label: Text("Catégorie")) { ForEach(TournamentCategory.allCases) { type in - Text(type.localizedLabel(.wide)).tag(type) + Text(type.localizedLabel(.title)).tag(type) } } Picker(selection: $tournament.federalAgeCategory, label: Text("Limite d'âge")) { diff --git a/PadelClub/Views/Club/ClubDetailView.swift b/PadelClub/Views/Club/ClubDetailView.swift index cb3b23b..b04b4f0 100644 --- a/PadelClub/Views/Club/ClubDetailView.swift +++ b/PadelClub/Views/Club/ClubDetailView.swift @@ -213,6 +213,16 @@ struct ClubDetailView: View { } } } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .keyboardType(.alphabet) .autocorrectionDisabled() .defaultFocus($focusedField, ._name, priority: .automatic) diff --git a/PadelClub/Views/Club/ClubSearchView.swift b/PadelClub/Views/Club/ClubSearchView.swift index fc022f1..9c5378c 100644 --- a/PadelClub/Views/Club/ClubSearchView.swift +++ b/PadelClub/Views/Club/ClubSearchView.swift @@ -385,7 +385,7 @@ struct ClubSearchView: View { LabeledContent { Text(club.distance(from: locationManager.location)) } label: { - Text(club.nom) + Text(club.nom).lineLimit(1) Text(club.ville).font(.caption) } } diff --git a/PadelClub/Views/Club/CourtView.swift b/PadelClub/Views/Club/CourtView.swift index 8986cc4..c4e89f8 100644 --- a/PadelClub/Views/Club/CourtView.swift +++ b/PadelClub/Views/Club/CourtView.swift @@ -12,6 +12,7 @@ struct CourtView: View { @EnvironmentObject var dataStore: DataStore @Bindable var court: Court @State private var name: String = "" + @FocusState var focusedField: Court.CodingKeys? init(court: Court) { self.court = court @@ -23,6 +24,7 @@ struct CourtView: View { Section { LabeledContent { TextField("Nom", text: $name) + .focused($focusedField, equals: ._name) .autocorrectionDisabled() .keyboardType(.alphabet) .multilineTextAlignment(.trailing) @@ -71,6 +73,16 @@ struct CourtView: View { Logger.error(error) } } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .navigationTitle(court.courtTitle()) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) diff --git a/PadelClub/Views/Components/FooterButtonView.swift b/PadelClub/Views/Components/FooterButtonView.swift index a710b18..7730639 100644 --- a/PadelClub/Views/Components/FooterButtonView.swift +++ b/PadelClub/Views/Components/FooterButtonView.swift @@ -11,13 +11,15 @@ fileprivate let defaultConfirmationMessage = "Êtes-vous sûr de vouloir faire c struct FooterButtonView: View { var role: ButtonRole? = nil + var systemImage: String? = nil let title: String let confirmationMessage: String let action: () -> () @State private var askConfirmation: Bool = false - init(_ title: String, role: ButtonRole? = nil, confirmationMessage: String? = nil, action: @escaping () -> Void) { + init(_ title: String, role: ButtonRole? = nil, systemImage: String? = nil, confirmationMessage: String? = nil, action: @escaping () -> Void) { self.title = title + self.systemImage = systemImage self.action = action self.role = role self.confirmationMessage = confirmationMessage ?? defaultConfirmationMessage @@ -31,8 +33,16 @@ struct FooterButtonView: View { action() } } label: { - Text(title) - .underline() + if let systemImage { + HStack { + Text(title) + .underline() + Image(systemName: systemImage).font(.caption) + } + } else { + Text(title) + .underline() + } } .buttonStyle(.borderless) .confirmationDialog("Confirmation", diff --git a/PadelClub/Views/Components/GenericDestinationPickerView.swift b/PadelClub/Views/Components/GenericDestinationPickerView.swift index a7f4871..59079c5 100644 --- a/PadelClub/Views/Components/GenericDestinationPickerView.swift +++ b/PadelClub/Views/Components/GenericDestinationPickerView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TipKit struct GenericDestinationPickerView: View { @EnvironmentObject var dataStore: DataStore @@ -49,6 +50,9 @@ struct GenericDestinationPickerView: .contentShape(Capsule()) } } + .selectableTipViewModifier(selectable: destination) { + selectedDestination = destination + } .padding() .background { Capsule() diff --git a/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift b/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift index 948dfa6..64abb0f 100644 --- a/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift +++ b/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift @@ -20,7 +20,8 @@ struct GroupStageSettingsView: View { @State private var presentConfirmationButton: Bool = false @State private var size: Int @State private var courtIndex: Int - + @FocusState var focusedField: GroupStage.CodingKeys? + init(groupStage: GroupStage) { _groupStage = Bindable(groupStage) _groupStageName = .init(wrappedValue: groupStage.name ?? "") @@ -37,6 +38,8 @@ struct GroupStageSettingsView: View { Section { TextField("Nom de la poule", text: $groupStageName) .keyboardType(.alphabet) + .focused($focusedField, equals: ._name) + .submitLabel(.done) .frame(maxWidth: .infinity) .onSubmit { groupStageName = groupStageName.trimmed @@ -152,6 +155,16 @@ struct GroupStageSettingsView: View { presentConfirmationButton = true } } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .navigationTitle("Paramètres") .toolbarBackground(.visible, for: .navigationBar) } diff --git a/PadelClub/Views/GroupStage/Components/GroupStageTeamView.swift b/PadelClub/Views/GroupStage/Components/GroupStageTeamView.swift index 0d87d01..af1b072 100644 --- a/PadelClub/Views/GroupStage/Components/GroupStageTeamView.swift +++ b/PadelClub/Views/GroupStage/Components/GroupStageTeamView.swift @@ -37,12 +37,22 @@ struct GroupStageTeamView: View { } } + private func _editingOptions() -> [EditablePlayerView.PlayerEditingOption] { + if tournament.isFree() { + return [.licenceId, .name, .presence] + } else { + return [.licenceId, .name, .payment] + } + } + var body: some View { List { Section { + if let name = team.name { + Text(name).foregroundStyle(.secondary) + } ForEach(team.players()) { player in - EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) - .environmentObject(tournament.tournamentStore) + EditablePlayerView(player: player, editingOptions: _editingOptions()) } } diff --git a/PadelClub/Views/GroupStage/GroupStageView.swift b/PadelClub/Views/GroupStage/GroupStageView.swift index 5b04f71..8a2e134 100644 --- a/PadelClub/Views/GroupStage/GroupStageView.swift +++ b/PadelClub/Views/GroupStage/GroupStageView.swift @@ -57,12 +57,24 @@ struct GroupStageView: View { MatchListView(section: "prêt à démarrer", matches: availableToStart, hideWhenEmpty: true) .listRowView(isActive: availableToStart.isEmpty == false, color: .green, hideColorVariation: true) MatchListView(section: "à lancer", matches: groupStage.readyMatches(playedMatches: playedMatches), hideWhenEmpty: true) - MatchListView(section: "terminés", matches: groupStage.finishedMatches(playedMatches: playedMatches), isExpanded: false) + MatchListView(section: "terminés", matches: groupStage.finishedMatches(playedMatches: playedMatches), hideWhenEmpty: playedMatches.isEmpty || playedMatches.flatMap({ $0.teamScores }).isEmpty, isExpanded: false) if playedMatches.isEmpty { RowButtonView("Créer les matchs de poules") { groupStage.buildMatches() } + } else if groupStage.step > 0, playedMatches.flatMap({ $0.teamScores }).isEmpty { + Section { + RowButtonView("Préparer les matchs") { + playedMatches.forEach { match in + match.updateTeamScores() + } + } + .disabled(tournament.groupStagesAreOver(atStep: 0) == false) + } footer: { + Text("La première phase doit être terminée avant de pouvoir préparer les matchs de la deuxième phase de poule.") + } + } } .toolbar { @@ -70,7 +82,7 @@ struct GroupStageView: View { _groupStageMenuView() } } - .navigationTitle(groupStage.groupStageTitle()) + .navigationTitle(groupStage.groupStageTitle(.title)) } private enum GroupStageSortingMode { @@ -105,7 +117,7 @@ struct GroupStageView: View { var body: some View { ForEach(0..<(groupStage.size), id: \.self) { index in - if let team = _teamAt(atIndex: index), let groupStagePosition = team.groupStagePosition { + if let team = _teamAt(atIndex: index), let groupStagePosition = team.groupStagePositionAtStep(groupStage.step) { NavigationLink { GroupStageTeamView(groupStage: groupStage, team: team) .environment(self.tournament) @@ -125,19 +137,20 @@ struct GroupStageView: View { HStack { VStack(alignment: .leading) { if let teamName = team.name { - Text(teamName).foregroundStyle(.secondary) - } - ForEach(team.players()) { player in - Text(player.playerLabel()).lineLimit(1) - .overlay { - if player.hasArrived && team.isHere() == false { - Color.green.opacity(0.6) + Text(teamName).font(.title3) + } else { + ForEach(team.players()) { player in + Text(player.playerLabel()).lineLimit(1) + .overlay { + if player.hasArrived && team.isHere() == false { + Color.green.opacity(0.6) + } } - } + } } } Spacer() - if let score = groupStage.scoreLabel(forGroupStagePosition: groupStagePosition, score: scores?.first(where: { $0.team.groupStagePosition == groupStagePosition })) { + if let score = groupStage.scoreLabel(forGroupStagePosition: groupStagePosition, score: scores?.first(where: { $0.team.groupStagePositionAtStep(groupStage.step) == groupStagePosition })) { VStack(alignment: .trailing) { HStack(spacing: 0.0) { Text(score.wins) diff --git a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift index e0f29dc..ab40d64 100644 --- a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift +++ b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift @@ -10,9 +10,10 @@ import LeStorage struct GroupStagesSettingsView: View { @EnvironmentObject var dataStore: DataStore - + @Environment(\.dismiss) private var dismiss @Environment(Tournament.self) var tournament: Tournament @State private var generationDone: Bool = false + let step: Int var tournamentStore: TournamentStore { return self.tournament.tournamentStore @@ -88,7 +89,6 @@ struct GroupStagesSettingsView: View { } else if let groupStageLoserBracket = tournament.groupStageLoserBracket() { RowButtonView("Supprimer les matchs de classements", role: .destructive) { do { - try groupStageLoserBracket.deleteDependencies() try tournamentStore.rounds.delete(instance: groupStageLoserBracket) } catch { Logger.error(error) @@ -96,6 +96,30 @@ struct GroupStagesSettingsView: View { } } } + + if tournament.lastStep() == 0, step == 0 { + Section { + RowButtonView("Ajouter une phase de poule", role: .destructive) { + tournament.addNewGroupStageStep() + } + } footer: { + Text("Padel Club peut vous créer une 2ème phase de poule utilisant les résultats de la première phase : les premiers de chaque poule joueront ensemble et ainsi de suite.") + } + } else if step > 0 { + Section { + RowButtonView("Supprimer cette phase de poule", role: .destructive) { + let groupStages = tournament.groupStages(atStep: tournament.lastStep()) + do { + try tournament.tournamentStore.groupStages.delete(contentOfs: groupStages) + } catch { + Logger.error(error) + } + + dismiss() + } + } + + } #if DEBUG Section { diff --git a/PadelClub/Views/GroupStage/GroupStagesView.swift b/PadelClub/Views/GroupStage/GroupStagesView.swift index 7274171..6f3355d 100644 --- a/PadelClub/Views/GroupStage/GroupStagesView.swift +++ b/PadelClub/Views/GroupStage/GroupStagesView.swift @@ -12,6 +12,7 @@ struct GroupStagesView: View { @State var tournament: Tournament @State private var selectedDestination: GroupStageDestination? @EnvironmentObject var dataStore: DataStore + let step: Int enum GroupStageDestination: Selectable, Identifiable, Equatable { static func == (lhs: GroupStagesView.GroupStageDestination, rhs: GroupStagesView.GroupStageDestination) -> Bool { @@ -77,17 +78,18 @@ struct GroupStagesView: View { } var allMatches: [Match] { - tournament.groupStagesMatches() + tournament.groupStagesMatches(atStep: step) } - init(tournament: Tournament) { + init(tournament: Tournament, step: Int = 0) { self.tournament = tournament + self.step = step if tournament.shouldVerifyGroupStage { _selectedDestination = State(wrappedValue: nil) } else if tournament.unsortedTeams().filter({ $0.groupStagePosition != nil }).isEmpty { _selectedDestination = State(wrappedValue: nil) } else { - let gs = tournament.getActiveGroupStage() + let gs = tournament.getActiveGroupStage(atStep: step) if let gs { _selectedDestination = State(wrappedValue: .groupStage(gs)) } @@ -96,7 +98,7 @@ struct GroupStagesView: View { func allDestinations() -> [GroupStageDestination] { var allDestinations : [GroupStageDestination] = [.all(tournament)] - let groupStageDestinations : [GroupStageDestination] = tournament.groupStages().map { GroupStageDestination.groupStage($0) } + let groupStageDestinations : [GroupStageDestination] = tournament.groupStages(atStep: step).map { GroupStageDestination.groupStage($0) } if let loserBracket = tournament.groupStageLoserBracket() { allDestinations.insert(.loserBracket(loserBracket), at: 0) } @@ -158,10 +160,11 @@ struct GroupStagesView: View { case .loserBracket(let loserBracket): LoserBracketFromGroupStageView(loserBracket: loserBracket).id(loserBracket.id) case nil: - GroupStagesSettingsView() + GroupStagesSettingsView(step: step) .navigationTitle("Réglages") } } + .environment(tournament) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) } diff --git a/PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift b/PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift index 776e75f..15f41b1 100644 --- a/PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift +++ b/PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift @@ -33,9 +33,13 @@ struct LoserBracketFromGroupStageView: View { List { if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false { Section { - RowButtonView("Ajouter un match", role: .destructive) { - _addNewMatch() - } + _addButton() + } + + Section { + _smartGenerationButton() + } footer: { + Text("La génération intelligente ajoutera un match par rang entre 2 poules. Si vos poules sont terminées, Padel Club placera les équipes automatiquement.") } } @@ -75,12 +79,10 @@ struct LoserBracketFromGroupStageView: View { ContentUnavailableView { Label("Aucun match de classement", systemImage: "figure.tennis") } description: { - Text("Vous n'avez créé aucun match de classement entre les perdants de poules.") + Text("Vous n'avez créé aucun match de classement entre les perdants de poules. La génération intelligente ajoutera un match par rang entre 2 poules") } actions: { - RowButtonView("Ajouter un match") { - isEditingLoserBracketGroupStage = true - _addNewMatch() - } + _addButton() + _smartGenerationButton() } } } @@ -117,15 +119,28 @@ struct LoserBracketFromGroupStageView: View { let displayableMatches = loserBracket.playedMatches().sorted(by: \.index) do { - for match in displayableMatches { - try match.deleteDependencies() - } try tournamentStore.matches.delete(contentOfs: displayableMatches) } catch { Logger.error(error) } } + + private func _smartGenerationButton() -> some View { + RowButtonView("Génération intelligente", role: .destructive, confirmationMessage: displayableMatches.isEmpty ? nil : "Les matchs de classement de poules déjà existants seront supprimés") { + isEditingLoserBracketGroupStage = true + _deleteAllMatches() + tournament.generateSmartLoserGroupStageBracket() + } + } + + private func _addButton() -> some View { + RowButtonView("Ajouter un match") { + isEditingLoserBracketGroupStage = true + _addNewMatch() + } + } + } struct GroupStageLoserBracketMatchFooterView: View { @@ -155,7 +170,6 @@ struct GroupStageLoserBracketMatchFooterView: View { Spacer() FooterButtonView("Effacer", role: .destructive) { do { - try match.deleteDependencies() try match.tournamentStore.matches.delete(instance: match) } catch { Logger.error(error) @@ -201,5 +215,5 @@ struct GroupStageLoserBracketMatchFooterView: View { } catch { Logger.error(error) } - } + } } diff --git a/PadelClub/Views/GroupStage/Shared/GroupStageTeamReplacementView.swift b/PadelClub/Views/GroupStage/Shared/GroupStageTeamReplacementView.swift index b01901b..cf613a4 100644 --- a/PadelClub/Views/GroupStage/Shared/GroupStageTeamReplacementView.swift +++ b/PadelClub/Views/GroupStage/Shared/GroupStageTeamReplacementView.swift @@ -54,7 +54,7 @@ struct GroupStageTeamReplacementView: View { Section { Picker(selection: $selectedPlayer) { HStack { - Text("Toute l'équipe") + Text(team.name ?? "Toute l'équipe") Spacer() Text(team.weight.formatted()).bold() } @@ -123,7 +123,7 @@ struct GroupStageTeamReplacementView: View { private func _searchLinkView(_ teamRange: TeamRegistration.TeamRange) -> some View { NavigationStack { let tournament = team.tournamentObject() - SelectablePlayerListView(searchField: _searchableRange(teamRange), dataSet: .favoriteClubs, filterOption: tournament?.tournamentCategory.playerFilterOption ?? .all, sortOption: .rank, showFemaleInMaleAssimilation: true, tokens: [_searchToken(teamRange)], hidePlayers: tournament?.selectedPlayers().compactMap { $0.licenceId }) + SelectablePlayerListView(isPresented: false, searchField: _searchableRange(teamRange), dataSet: .favoriteClubs, filterOption: tournament?.tournamentCategory.playerFilterOption ?? .all, sortOption: .rank, showFemaleInMaleAssimilation: true, tokens: [_searchToken(teamRange)], hidePlayers: tournament?.selectedPlayers().compactMap { $0.licenceId }) } } diff --git a/PadelClub/Views/Match/Components/MatchDateView.swift b/PadelClub/Views/Match/Components/MatchDateView.swift index d969252..8034631 100644 --- a/PadelClub/Views/Match/Components/MatchDateView.swift +++ b/PadelClub/Views/Match/Components/MatchDateView.swift @@ -93,7 +93,7 @@ struct MatchDateView: View { .foregroundStyle(Color.master) .underline() } else { - Text("en attente") + Text("démarrer") .foregroundStyle(Color.master) .underline() } diff --git a/PadelClub/Views/Match/Components/MatchTeamDetailView.swift b/PadelClub/Views/Match/Components/MatchTeamDetailView.swift index 435f181..cf952d0 100644 --- a/PadelClub/Views/Match/Components/MatchTeamDetailView.swift +++ b/PadelClub/Views/Match/Components/MatchTeamDetailView.swift @@ -32,13 +32,26 @@ struct MatchTeamDetailView: View { private func _teamDetailView(_ team: TeamRegistration, inTournament tournament: Tournament?) -> some View { Section { ForEach(team.players()) { player in - EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) + EditablePlayerView(player: player, editingOptions: _editingOptions()) } } header: { TeamHeaderView(team: team, teamIndex: tournament?.indexOf(team: team)) } } + private func _isFree() -> Bool { + let tournament = match.currentTournament() + return tournament?.isFree() == true + } + + private func _editingOptions() -> [EditablePlayerView.PlayerEditingOption] { + if _isFree() { + return [.licenceId, .name, .presence] + } else { + return [.licenceId, .name, .payment] + } + } + } //#Preview { diff --git a/PadelClub/Views/Match/Components/PlayerBlockView.swift b/PadelClub/Views/Match/Components/PlayerBlockView.swift index 444aa94..5f5b278 100644 --- a/PadelClub/Views/Match/Components/PlayerBlockView.swift +++ b/PadelClub/Views/Match/Components/PlayerBlockView.swift @@ -57,19 +57,21 @@ struct PlayerBlockView: View { } if let name = team?.name { - Text(name).foregroundStyle(.secondary) - } - ForEach(names, id: \.self) { name in - Text(name).lineLimit(1) + Text(name).font(.title3) + } else { + ForEach(names, id: \.self) { name in + Text(name).lineLimit(1) + } } } else { ZStack(alignment: .leading) { VStack { if let name = team?.name { - Text(name).foregroundStyle(.secondary) + Text(name).font(.title3) + } else { + Text("longLabelPlayerOne").lineLimit(1) + Text("longLabelPlayerTwo").lineLimit(1) } - Text("longLabelPlayerOne").lineLimit(1) - Text("longLabelPlayerTwo").lineLimit(1) } .opacity(0) Text(_defaultLabel()).foregroundStyle(.secondary).lineLimit(1) diff --git a/PadelClub/Views/Match/MatchDetailView.swift b/PadelClub/Views/Match/MatchDetailView.swift index bf7c291..f8766d1 100644 --- a/PadelClub/Views/Match/MatchDetailView.swift +++ b/PadelClub/Views/Match/MatchDetailView.swift @@ -105,32 +105,34 @@ struct MatchDetailView: View { RowButtonView("Saisir les résultats", systemImage: "list.clipboard") { self._editScores() } + .disabled(match.teams().count < 2) } - let players = self.match.teams().flatMap { $0.players() } - let unpaid = players.filter({ $0.hasPaid() == false }) - - if unpaid.isEmpty == false { - Section { - DisclosureGroup { - ForEach(unpaid) { player in + if self.match.currentTournament()?.isFree() == false { + let players = self.match.teams().flatMap { $0.players() } + let unpaid = players.filter({ $0.hasPaid() == false }) + + if unpaid.isEmpty == false { + Section { + DisclosureGroup { + ForEach(unpaid) { player in + LabeledContent { + PlayerPayView(player: player) + .environmentObject(tournamentStore) + } label: { + Text(player.playerLabel()) + } + } + } label: { LabeledContent { - PlayerPayView(player: player) - .environmentObject(tournamentStore) + Text(unpaid.count.formatted() + " / " + players.count.formatted()) } label: { - Text(player.playerLabel()) + Text("Encaissement manquant") } } - } label: { - LabeledContent { - Text(unpaid.count.formatted() + " / " + players.count.formatted()) - } label: { - Text("Encaissement manquant") - } } } } - menuView } .sheet(isPresented: $showDetails) { @@ -423,9 +425,9 @@ struct MatchDetailView: View { let rotationDuration = match.getDuration() Picker(selection: $startDateSetup) { if match.isReady() { + Text("Tout de suite").tag(MatchDateSetup.now) Text("Dans 5 minutes").tag(MatchDateSetup.inMinutes(5)) Text("Dans 15 minutes").tag(MatchDateSetup.inMinutes(15)) - Text("Tout de suite").tag(MatchDateSetup.now) } Text("Précédente rotation").tag(MatchDateSetup.inMinutes(-rotationDuration)) Text("Prochaine rotation").tag(MatchDateSetup.inMinutes(rotationDuration)) @@ -464,11 +466,7 @@ struct MatchDetailView: View { Text("Au hasard parmi les libres").tag(MatchFieldSetup.random) Text("Au hasard").tag(MatchFieldSetup.fullRandom) //Text("Premier disponible").tag(MatchFieldSetup.firstAvailable) - if let club = match.currentTournament()?.club() { - ForEach(0.. some View { - PasteButton(payloadType: String.self) { strings in - guard let first = strings.first else { return } - quickAccessScreen = .inscription(pasteString: first) + Button { + quickAccessScreen = .inscription + } label: { + Image(systemName: "person.crop.circle.badge.plus") + .resizable() + .scaledToFit() + .frame(minHeight: 32) } - .foregroundStyle(.master) - .labelStyle(.iconOnly) - .buttonBorderShape(.capsule) + .accessibilityLabel("Ajouter une équipe") + +// if pasteButtonIsDisplayed == nil || pasteButtonIsDisplayed == true { +// PasteButton(payloadType: String.self) { strings in +// let first = strings.first ?? "aucun texte" +// quickAccessScreen = .inscription(pasteString: first) +// } +// .foregroundStyle(.master) +// .labelStyle(.iconOnly) +// .buttonBorderShape(.capsule) +// .onAppear { +// pasteButtonIsDisplayed = true +// } +// } else if let pasteButtonIsDisplayed, pasteButtonIsDisplayed == false { +// } } var body: some View { @@ -189,6 +206,10 @@ struct ActivityView: View { .navigationDestination(for: Tournament.self) { tournament in TournamentView(tournament: tournament) } +// .onDisappear(perform: { +// pasteButtonIsDisplayed = nil +// print("disappearing", "pasteButtonIsDisplayed", pasteButtonIsDisplayed) +// }) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button { @@ -291,28 +312,41 @@ struct ActivityView: View { } .sheet(item: $quickAccessScreen) { screen in switch screen { - case .inscription(let pasteString): + case .inscription: NavigationStack { List { - Section { - Text(pasteString) - } header: { - Text("Contenu du presse-papier") + + if let pasteString { + Section { + Text(pasteString) + .frame(maxWidth: .infinity) + .overlay { + if pasteString.isEmpty { + Text("Le presse-papier est vide") + .foregroundStyle(.secondary) + .italic() + } + } + } header: { + Text("Contenu du presse-papier") + } } - + Section { ForEach(getRunningTournaments()) { tournament in NavigationLink { AddTeamView(tournament: tournament, pasteString: pasteString, editedTeam: nil) } label: { - VStack(alignment: .leading) { + LabeledContent { + Text(tournament.unsortedTeamsWithoutWO().count.formatted()) + } label: { Text(tournament.tournamentTitle()) - Text(tournament.formattedDate()).foregroundStyle(.secondary) + Text(tournament.formattedDate()) } } } } header: { - Text("À coller dans la liste d'inscription") + Text("Ajouter à la liste d'inscription") } } .toolbar { @@ -321,6 +355,26 @@ struct ActivityView: View { self.quickAccessScreen = nil } } + + ToolbarItem(placement: .topBarTrailing) { + Button { + pasteString = UIPasteboard.general.string ?? "" + } label: { + Label("Coller", systemImage: "doc.on.clipboard").labelStyle(.iconOnly) + } + .foregroundStyle(.master) + .labelStyle(.iconOnly) + .buttonBorderShape(.capsule) + } + + ToolbarItem(placement: .bottomBar) { + PasteButton(payloadType: String.self) { strings in + pasteString = strings.first ?? "" + } + .foregroundStyle(.master) + .labelStyle(.titleAndIcon) + .buttonBorderShape(.capsule) + } } .navigationTitle("Choix du tournoi") .navigationBarTitleDisplayMode(.inline) diff --git a/PadelClub/Views/Navigation/Agenda/CalendarView.swift b/PadelClub/Views/Navigation/Agenda/CalendarView.swift index 474fb28..b700129 100644 --- a/PadelClub/Views/Navigation/Agenda/CalendarView.swift +++ b/PadelClub/Views/Navigation/Agenda/CalendarView.swift @@ -93,11 +93,11 @@ struct CalendarView: View { if federalDataViewModel.isFederalTournamentValidForFilters(tournament, build: build) { if navigation.agendaDestination == .around { - NavigationLink(build.buildHolderTitle()) { + NavigationLink(build.buildHolderTitle(.wide)) { TournamentSubscriptionView(federalTournament: tournament, build: build, user: dataStore.user) } } else { - Button(build.buildHolderTitle()) { + Button(build.buildHolderTitle(.wide)) { _createOrShow(federalTournament: tournament, existingTournament: event(forTournament: tournament)?.existingBuild(build), build: build) } } @@ -144,7 +144,9 @@ struct CalendarView: View { let filteredTournaments = tournaments let mappedItems = filteredTournaments.flatMap { tournamentHolder in (0.. { + Binding { + locationManager.lastError != nil + } set: { value in + } + } var body: some View { List { searchParametersView } + .alert(isPresented: showLastError, error: locationManager.lastError as? LocationManager.LocationError, actions: { + Button("Annuler", role: .cancel) { + + } + }) + .confirmationDialog("Attention", isPresented: $confirmSearch, titleVisibility: .visible) { + Button("Cherchez quand même") { + requestedToGetAllPages = true + runSearch() + } + + Button("Annuler", role: .cancel) { + + } + } message: { + Text("Aucune ville n'a été indiqué, il est préférable de se localiser ou d'indiquer une ville pour réduire le nombre de résultat.") + } .alert("Attention", isPresented: $presentAlert, actions: { Button { presentAlert = false @@ -70,7 +95,11 @@ struct TournamentLookUpView: View { ToolbarItem(placement: .bottomBar) { if revealSearchParameters { FooterButtonView("Lancer la recherche") { - runSearch() + if dataStore.appSettings.city.isEmpty { + confirmSearch = true + } else { + runSearch() + } } .disabled(searching) } else if searching { @@ -149,6 +178,9 @@ struct TournamentLookUpView: View { federalDataViewModel.searchAttemptCount += 1 federalDataViewModel.dayPeriod = dataStore.appSettings.dayPeriod federalDataViewModel.dayDuration = dataStore.appSettings.dayDuration + federalDataViewModel.levels = Set(levels) + federalDataViewModel.categories = Set(categories) + federalDataViewModel.ageCategories = Set(ages) Task { await getNewPage() @@ -194,6 +226,12 @@ struct TournamentLookUpView: View { let resultCommand = commands.first(where: { $0.results != nil }) if let newTournaments = resultCommand?.results?.items { newTournaments.forEach { ft in +// let isValid = ft.tournaments.anySatisfy({ build in +// let ageValid = ages.isEmpty ? true : ages.contains(build.age) +// let levelValid = levels.isEmpty ? true : levels.contains(build.level) +// let categoryValid = categories.isEmpty ? true : categories.contains(build.category) +// return ageValid && levelValid && categoryValid +// }) if tournaments.contains(where: { $0.id == ft.id }) == false { federalDataViewModel.searchedFederalTournaments.append(ft) } @@ -230,31 +268,6 @@ struct TournamentLookUpView: View { } } - @ViewBuilder - var searchContollerView: some View { - Section { - Button { - runSearch() - } label: { - HStack { - Label("Chercher un tournoi", systemImage: "magnifyingglass") - if searching { - Spacer() - ProgressView() - } - } - } - Button { - dataStore.appSettings.resetSearch() - locationManager.location = nil - locationManager.city = nil - revealSearchParameters = true - } label: { - Label("Ré-initialiser la recherche", systemImage: "xmark.circle") - } - } - } - @ViewBuilder var searchParametersView: some View { @Bindable var appSettings = dataStore.appSettings @@ -335,7 +348,7 @@ struct TournamentLookUpView: View { NavigationLink { List([TournamentLevel.p25, TournamentLevel.p100, TournamentLevel.p250, TournamentLevel.p500, TournamentLevel.p1000, TournamentLevel.p1500, TournamentLevel.p2000], selection: $appSettings.tournamentLevels) { type in - Text(type.localizedLabel()) + Text(type.localizedLevelLabel()) } .navigationTitle("Niveaux") .environment(\.editMode, Binding.constant(EditMode.active)) @@ -413,7 +426,7 @@ struct TournamentLookUpView: View { if dataStore.appSettings.tournamentLevels.isEmpty || dataStore.appSettings.tournamentLevels.count == TournamentLevel.allCases.count { Text("Tous les niveaux") } else { - Text(levels.map({ $0.localizedLabel() }).joined(separator: ", ")) + Text(levels.map({ $0.localizedLevelLabel() }).joined(separator: ", ")) } } diff --git a/PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift b/PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift index fcf20e0..91dedc7 100644 --- a/PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift +++ b/PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift @@ -96,7 +96,7 @@ struct TournamentSubscriptionView: View { Text(federalTournament.clubLabel()) } LabeledContent("Épreuve") { - Text(build.buildHolderTitle()) + Text(build.buildHolderTitle(.wide)) } LabeledContent("JAP") { @@ -292,24 +292,24 @@ struct TournamentSubscriptionView: View { var messageBody: String { let bonjourOuBonsoir = Date().timeOfDay.hello let bonneSoireeOuBonneJournee = Date().timeOfDay.goodbye - let body = [["\(bonjourOuBonsoir),\n\nJe souhaiterais inscrire mon équipe au tournoi : ", build.buildHolderTitle(), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, "\nCordialement,\n", user.fullName() ?? bonneSoireeOuBonneJournee, "----------------------------------\nCe message a été préparé grâce à l'application Padel Club !\n\(URLs.appStore.rawValue)"].compactMap { $0 }.joined(separator: "\n") + "\n" + let body = [["\(bonjourOuBonsoir),\n\nJe souhaiterais inscrire mon équipe au tournoi : ", build.buildHolderTitle(.wide), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, "\nCordialement,\n", user.fullName() ?? bonneSoireeOuBonneJournee, "----------------------------------\nCe message a été préparé grâce à l'application Padel Club !\n\(URLs.appStore.rawValue)"].compactMap { $0 }.joined(separator: "\n") + "\n" return body } var messageBodyShort: String { let bonjourOuBonsoir = Date().timeOfDay.hello let bonneSoireeOuBonneJournee = Date().timeOfDay.goodbye - let body = [["\(bonjourOuBonsoir),\n\nJe souhaiterais inscrire mon équipe au tournoi : ", build.buildHolderTitle(), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, "\nCordialement,\n", user.fullName() ?? bonneSoireeOuBonneJournee, "----------------------------------\nCe message a été préparé grâce à l'application Padel Club !\n\(URLs.appStore.rawValue)"].compactMap { $0 }.joined(separator: "\n") + "\n" + let body = [["\(bonjourOuBonsoir),\n\nJe souhaiterais inscrire mon équipe au tournoi : ", build.buildHolderTitle(.wide), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, "\nCordialement,\n", user.fullName() ?? bonneSoireeOuBonneJournee, "----------------------------------\nCe message a été préparé grâce à l'application Padel Club !\n\(URLs.appStore.rawValue)"].compactMap { $0 }.joined(separator: "\n") + "\n" return body } var noteCalendar: String { - let body = [[build.buildHolderTitle(), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, federalTournament.calendarNoteMessage()].compactMap { $0 }.joined(separator: "\n") + "\n" + let body = [[build.buildHolderTitle(.wide), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, federalTournament.calendarNoteMessage()].compactMap { $0 }.joined(separator: "\n") + "\n" return body } var messageSubject: String { - let subject = [build.buildHolderTitle(), federalTournament.clubLabel()].compacted().joined(separator: " ") + let subject = [build.buildHolderTitle(.wide), federalTournament.clubLabel()].compacted().joined(separator: " ") return subject } diff --git a/PadelClub/Views/Navigation/Toolbox/RankCalculatorView.swift b/PadelClub/Views/Navigation/Toolbox/RankCalculatorView.swift index 0ebd1bf..28b4050 100644 --- a/PadelClub/Views/Navigation/Toolbox/RankCalculatorView.swift +++ b/PadelClub/Views/Navigation/Toolbox/RankCalculatorView.swift @@ -17,7 +17,7 @@ struct RankCalculatorView: View { Section { HStack { let ordinal = NumberFormatter.ordinal.string(from: NSNumber(value:rank))! - Text("\(ordinal) d'un \(tournamentLevel.localizedLabel()) de \(count.localizedLabel()) équipes:") + Text("\(ordinal) d'un \(tournamentLevel.localizedLevelLabel()) de \(count.localizedLabel()) équipes:") Spacer() Text(tournamentLevel.points(for: rank-1, count: count.rawValue).formatted(.number.sign(strategy: .always()))) } @@ -25,7 +25,7 @@ struct RankCalculatorView: View { Section { Picker(selection: $tournamentLevel) { ForEach(TournamentLevel.allCases) { level in - Text(level.localizedLabel()).tag(level) + Text(level.localizedLevelLabel()).tag(level) } } label: { Label("Niveau", systemImage: "gauge.medium") diff --git a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift index f0d9198..8c9dd36 100644 --- a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift +++ b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift @@ -7,6 +7,7 @@ import SwiftUI import LeStorage +import Zip struct ToolboxView: View { @EnvironmentObject var dataStore: DataStore @@ -128,7 +129,7 @@ struct ToolboxView: View { Section { NavigationLink { - SelectablePlayerListView() + SelectablePlayerListView(isPresented: false) } label: { Label("Rechercher un joueur", systemImage: "person.fill.viewfinder") } @@ -210,14 +211,38 @@ struct ToolboxView: View { } .navigationTitle(TabDestination.toolbox.title) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .topBarLeading) { Link(destination: URLs.appStore.url) { Text("v\(PadelClubApp.appVersion)") } } + ToolbarItem(placement: .topBarTrailing) { + Menu { + ShareLink(item: URLs.appStore.url) { + Label("Lien AppStore", systemImage: "link") + } + if let zip = _getZip() { + ShareLink(item: zip) { + Label("Mes données", systemImage: "server.rack") + } + } + } label: { + Label("Partagez", systemImage: "square.and.arrow.up").labelStyle(.iconOnly) + } + } } } } + + private func _getZip() -> URL? { + do { + let filePath = try Club.storageDirectoryPath() + return try Zip.quickZipFiles([filePath], fileName: "backup") // Zip + } catch { + Logger.error(error) + return nil + } + } } //#Preview { diff --git a/PadelClub/Views/Planning/GroupStageScheduleEditorView.swift b/PadelClub/Views/Planning/GroupStageScheduleEditorView.swift index 32d2347..50b7cf4 100644 --- a/PadelClub/Views/Planning/GroupStageScheduleEditorView.swift +++ b/PadelClub/Views/Planning/GroupStageScheduleEditorView.swift @@ -27,7 +27,7 @@ struct GroupStageScheduleEditorView: View { } var body: some View { - GroupStageDatePickingView(title: groupStage.groupStageTitle(), startDate: $startDate, currentDate: $groupStage.startDate, duration: groupStage.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)) { + GroupStageDatePickingView(title: groupStage.groupStageTitle(.title), startDate: $startDate, currentDate: $groupStage.startDate, duration: groupStage.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)) { groupStage.startDate = startDate tournament.matchScheduler()?.updateGroupStageSchedule(tournament: tournament, specificGroupStage: groupStage) _save() diff --git a/PadelClub/Views/Planning/PlanningByCourtView.swift b/PadelClub/Views/Planning/PlanningByCourtView.swift index 96302fd..25cc794 100644 --- a/PadelClub/Views/Planning/PlanningByCourtView.swift +++ b/PadelClub/Views/Planning/PlanningByCourtView.swift @@ -40,6 +40,7 @@ struct PlanningByCourtView: View { var body: some View { List { _byCourtView() + .id(selectedCourt) } .overlay { if matches.allSatisfy({ $0.startDate == nil }) { diff --git a/PadelClub/Views/Planning/PlanningSettingsView.swift b/PadelClub/Views/Planning/PlanningSettingsView.swift index 1286f4d..ed03bc6 100644 --- a/PadelClub/Views/Planning/PlanningSettingsView.swift +++ b/PadelClub/Views/Planning/PlanningSettingsView.swift @@ -52,7 +52,7 @@ struct PlanningSettingsView: View { Section { DatePicker(selection: $tournament.startDate) { - Text(tournament.startDate.formatted(.dateTime.weekday(.wide)).capitalized) + Text(tournament.startDate.formatted(.dateTime.weekday(.wide)).capitalized).lineLimit(1) } LabeledContent { StepperView(count: $tournament.dayDuration, minimum: 1) @@ -114,7 +114,7 @@ struct PlanningSettingsView: View { } let allMatches = tournament.allMatches() - let allGroupStages = tournament.groupStages() + let allGroupStages = tournament.allGroupStages() let allRounds = tournament.allRounds() let matchesWithDate = allMatches.filter({ $0.startDate != nil }) let groupStagesWithDate = allGroupStages.filter({ $0.startDate != nil }) @@ -240,9 +240,9 @@ struct PlanningSettingsView: View { let value = tournament.getGroupStageChunkValue() if parallelType == false { if value > 1 { - Text("\(value.formatted()) poules commenceront en parallèle") + Text("\(value.formatted()) poules en parallèle") } else { - Text("une poule sera jouer à la fois") + Text("une poule sera jouée à la fois") } } } diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index e490a08..903ec08 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -16,6 +16,23 @@ struct PlanningView: View { @State private var timeSlots: [Date:[Match]] @State private var days: [Date] @State private var keys: [Date] + @State private var filterOption: PlanningFilterOption = .byDefault + + enum PlanningFilterOption: Int, CaseIterable, Identifiable { + var id: Int { self.rawValue } + + case byDefault + case byCourt + + func localizedPlanningLabel() -> String { + switch self { + case .byCourt: + return "Par terrain" + case .byDefault: + return "Par défaut" + } + } + } init(matches: [Match], selectedScheduleDestination: Binding) { self.matches = matches @@ -30,6 +47,24 @@ struct PlanningView: View { List { _bySlotView() } + .toolbar(content: { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker(selection: $filterOption) { + ForEach(PlanningFilterOption.allCases) { + Text($0.localizedPlanningLabel()).tag($0) + } + } label: { + Text("Option de filtrage") + } + .labelsHidden() + .pickerStyle(.inline) + } label: { + Label("Filtrer", systemImage: "line.3.horizontal.decrease.circle") + .symbolVariant(filterOption == .byCourt ? .fill : .none) + } + } + }) .overlay { if matches.allSatisfy({ $0.startDate == nil }) { ContentUnavailableView { @@ -53,7 +88,7 @@ struct PlanningView: View { ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in if let _matches = timeSlots[key] { DisclosureGroup { - ForEach(_matches) { match in + ForEach(_matches.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting)) { match in NavigationLink { MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) } label: { @@ -63,7 +98,7 @@ struct PlanningView: View { } } label: { if let groupStage = match.groupStageObject { - Text(groupStage.groupStageTitle()) + Text(groupStage.groupStageTitle(.title)) } else if let round = match.roundObject { Text(round.roundTitle()) } @@ -98,7 +133,14 @@ struct PlanningView: View { Text(self._formattedMatchCount(matches.count)) } label: { Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) - Text(Set(matches.compactMap { $0.roundTitle() }).joined(separator: ", ")) + let names = matches.sorted(by: \.computedOrder) + .compactMap({ $0.roundTitle() }) + .reduce(into: [String]()) { uniqueNames, name in + if !uniqueNames.contains(name) { + uniqueNames.append(name) + } + } + Text(names.joined(separator: ", ")) } } diff --git a/PadelClub/Views/Planning/SchedulerView.swift b/PadelClub/Views/Planning/SchedulerView.swift index 19d593d..333357e 100644 --- a/PadelClub/Views/Planning/SchedulerView.swift +++ b/PadelClub/Views/Planning/SchedulerView.swift @@ -43,7 +43,7 @@ struct SchedulerView: View { } } .onChange(of: tournament.groupStageMatchFormat) { - let groupStages = tournament.groupStages() + let groupStages = tournament.allGroupStages() groupStages.forEach { groupStage in groupStage.updateMatchFormat(tournament.groupStageMatchFormat) } @@ -68,7 +68,7 @@ struct SchedulerView: View { } } - ForEach(tournament.groupStages()) { + ForEach(tournament.allGroupStages()) { GroupStageScheduleEditorView(groupStage: $0, tournament: tournament) .id(UUID()) } diff --git a/PadelClub/Views/Player/Components/EditablePlayerView.swift b/PadelClub/Views/Player/Components/EditablePlayerView.swift index 130b127..b2955fb 100644 --- a/PadelClub/Views/Player/Components/EditablePlayerView.swift +++ b/PadelClub/Views/Player/Components/EditablePlayerView.swift @@ -14,6 +14,7 @@ struct EditablePlayerView: View { case payment case licenceId case name + case presence } @EnvironmentObject var dataStore: DataStore @@ -77,6 +78,13 @@ struct EditablePlayerView: View { Logger.error(error) } } + .onChange(of: player.hasArrived) { + do { + try self.tournamentStore.playerRegistrations.addOrUpdate(instance: player) + } catch { + Logger.error(error) + } + } } @ViewBuilder @@ -91,11 +99,6 @@ struct EditablePlayerView: View { Menu { Button { player.hasArrived.toggle() - do { - try self.tournamentStore.playerRegistrations.addOrUpdate(instance: player) - } catch { - Logger.error(error) - } } label: { Label("Présent", systemImage: player.hasArrived ? "checkmark.circle" : "circle") } @@ -172,6 +175,11 @@ struct EditablePlayerView: View { if editingOptions.contains(.payment) { Spacer() PlayerPayView(player: player) + } else if editingOptions.contains(.presence) { + Spacer() + FooterButtonView(player.hasArrived ? "Présent" : "Sur place ?", role: player.hasArrived ? nil : .cancel, systemImage: player.hasArrived ? "checkmark" : nil) { + player.hasArrived.toggle() + } } } } diff --git a/PadelClub/Views/Player/Components/PlayerPopoverView.swift b/PadelClub/Views/Player/Components/PlayerPopoverView.swift index f1dbe6e..9bd893b 100644 --- a/PadelClub/Views/Player/Components/PlayerPopoverView.swift +++ b/PadelClub/Views/Player/Components/PlayerPopoverView.swift @@ -31,7 +31,7 @@ struct PlayerPopoverView: View { @State private var source: String? - init(source: String?, sex: Int, requiredField: [PlayerCreationField] = [.firstName, .lastName], creationCompletionHandler: @escaping (PlayerRegistration) -> Void) { + init(source: String? = nil, sex: Int, requiredField: [PlayerCreationField] = [], creationCompletionHandler: @escaping (PlayerRegistration) -> Void) { if let source { let words = source.components(separatedBy: .whitespaces) if words.isEmpty == false { diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 3d65b72..f80848e 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -168,17 +168,22 @@ struct PlayerDetailView: View { } } } - .scrollDismissesKeyboard(.immediately) .onChange(of: player.hasArrived) { _save() } .onChange(of: player.sex) { _save() } - .onChange(of: player.computedRank) { - player.team()?.updateWeight(inTournamentCategory: tournament.tournamentCategory) - _save() - } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .headerProminence(.increased) .navigationTitle("Édition") .navigationBarTitleDisplayMode(.inline) diff --git a/PadelClub/Views/Round/LoserRoundSettingsView.swift b/PadelClub/Views/Round/LoserRoundSettingsView.swift index 08952fe..3b4706a 100644 --- a/PadelClub/Views/Round/LoserRoundSettingsView.swift +++ b/PadelClub/Views/Round/LoserRoundSettingsView.swift @@ -13,7 +13,15 @@ struct LoserRoundSettingsView: View { @Environment(\.isEditingTournamentSeed) private var isEditingTournamentSeed @Environment(Tournament.self) var tournament: Tournament @State var upperBracketRound: UpperRound + @State private var confirmationRequired: Bool = false + @State private var presentConfirmation: Bool = false + @State private var loserBracketMode: LoserBracketMode + init(upperBracketRound: UpperRound) { + self.upperBracketRound = upperBracketRound + _loserBracketMode = .init(wrappedValue: upperBracketRound.round.loserBracketMode) + } + var body: some View { List { Section { @@ -23,25 +31,31 @@ struct LoserRoundSettingsView: View { } Section { - @Bindable var round: Round = upperBracketRound.round - Picker(selection: $round.loserBracketMode) { + Picker(selection: $loserBracketMode) { ForEach(LoserBracketMode.allCases) { Text($0.localizedLoserBracketMode()).tag($0) } } label: { Text("Position des perdants") } - .onChange(of: round.loserBracketMode) { - do { - try self.tournament.tournamentStore.rounds.addOrUpdate(instance: upperBracketRound.round) - } catch { - Logger.error(error) + .onChange(of: loserBracketMode) { + if upperBracketRound.round.allLoserRoundMatches().anySatisfy({ $0.hasEnded() }) == false { + _refreshLoserBracketMode() + } else { + confirmationRequired = true } } } header: { Text("Matchs de classement") } footer: { - Text(upperBracketRound.round.loserBracketMode.localizedLoserBracketModeDescription()) + if confirmationRequired == false { + Text(upperBracketRound.round.loserBracketMode.localizedLoserBracketModeDescription()) + } else { + _footerViewConfirmationRequired() + .onTapGesture(perform: { + presentConfirmation = true + }) + } } Section { @@ -81,7 +95,58 @@ struct LoserRoundSettingsView: View { //todo proposer ici l'impression des matchs de classements peut-être? } + .confirmationDialog("Attention", isPresented: $presentConfirmation, actions: { + Button("Confirmer", role: .destructive) { + _refreshLoserBracketMode() + confirmationRequired = false + } + + Button("Annuler", role: .cancel) { + loserBracketMode = upperBracketRound.round.loserBracketMode + } + + }) + + } + + private func _refreshLoserBracketMode() { + let matches = upperBracketRound.round.loserRoundsAndChildren().flatMap({ $0._matches() }) + matches.forEach { match in + match.resetTeamScores(outsideOf: []) + match.resetMatch() + if loserBracketMode == .automatic { + match.updateTeamScores() + } + match.confirmed = false + } + + upperBracketRound.round.loserBracketMode = loserBracketMode + + if loserBracketMode == .automatic { + matches.forEach { match in + match.updateTeamScores() + } + } + + do { + try self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: matches) + } catch { + Logger.error(error) + } + + do { + try self.tournament.tournamentStore.rounds.addOrUpdate(instance: upperBracketRound.round) + } catch { + Logger.error(error) + } } + + private func _footerViewConfirmationRequired() -> some View { + Text("Au moins un match de classement est terminé, en modifiant ce réglage, les résultats de ces matchs de classement seront perdus.") + + + Text(" Modifier quand même ?").foregroundStyle(.red) + } + } //#Preview { diff --git a/PadelClub/Views/Round/RoundView.swift b/PadelClub/Views/Round/RoundView.swift index 12bda95..540b94a 100644 --- a/PadelClub/Views/Round/RoundView.swift +++ b/PadelClub/Views/Round/RoundView.swift @@ -259,7 +259,7 @@ struct RoundView: View { #if DEBUG Spacer() - Text(match.teamScores.count.formatted()) + Text(match.index.formatted() + " " + match.teamScores.count.formatted()) #endif } } footer: { @@ -275,6 +275,25 @@ struct RoundView: View { } } } + + if upperRound.round.index == 0, tournament.hasEnded() { + NavigationLink(value: Screen.rankings) { + LabeledContent { + if tournament.publishRankings == false { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.logoYellow) + } else { + Image(systemName: "checkmark") + .foregroundStyle(.green) + } + } label: { + Text("Classement final des équipes") + if tournament.publishRankings == false { + Text("Vérifiez le classement avant de publier").foregroundStyle(.logoRed) + } + } + } + } } .navigationDestination(isPresented: $showPrintScreen) { PrintSettingsView(tournament: tournament) @@ -327,13 +346,20 @@ struct RoundView: View { match.name = Match.setServerTitle(upperRound: round, matchIndex: match.indexInRound(in: matches)) } } + + let loserMatches = self.upperRound.loserMatches() + loserMatches.forEach { match in + match.name = match.roundTitle() + } + let allRoundMatches = tournament.allRoundMatches() + do { - try self.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches) + try tournament.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches) } catch { Logger.error(error) } - + if tournament.availableSeeds().isEmpty && tournament.availableQualifiedTeams().isEmpty { self.isEditingTournamentSeed.wrappedValue = false } diff --git a/PadelClub/Views/Shared/ImportedPlayerView.swift b/PadelClub/Views/Shared/ImportedPlayerView.swift index fd99bf8..f81ae56 100644 --- a/PadelClub/Views/Shared/ImportedPlayerView.swift +++ b/PadelClub/Views/Shared/ImportedPlayerView.swift @@ -12,6 +12,9 @@ struct ImportedPlayerView: View { var index: Int? = nil var showFemaleInMaleAssimilation: Bool = false var showProgression: Bool = false + var isAnimation: Bool { + player.getComputedRank() == 0 + } var body: some View { VStack(alignment: .leading) { @@ -39,74 +42,77 @@ struct ImportedPlayerView: View { } .font(.title3) .lineLimit(1) - HStack { - HStack(alignment: .top, spacing: 0) { - Text(player.formattedRank()).italic(player.isAssimilated) - .font(.title3) - .background { - if player.isNotFromCurrentDate() { - UnderlineView() + if isAnimation == false { + HStack { + HStack(alignment: .top, spacing: 0) { + Text(player.formattedRank()).italic(player.isAssimilated) + .font(.title3) + .background { + if player.isNotFromCurrentDate() { + UnderlineView() + } } + if let rank = player.getRank() { + Text(rank.ordinalFormattedSuffix()).italic(player.isAssimilated) + .font(.caption) + } + } + + if showProgression, player.getProgression() != 0 { + HStack(alignment: .top, spacing: 2) { + Text("(") + Text(player.getProgression().formatted(.number.sign(strategy: .always()))) + .foregroundStyle(player.getProgressionColor(progression: player.getProgression())) + Text(")") + }.font(.title3) + } + + if let pts = player.getPoints(), pts > 0 { + HStack(alignment: .lastTextBaseline, spacing: 0) { + Text(pts.formatted()).font(.title3) + Text(" pts").font(.caption) + } + } + + if let tournamentPlayed = player.tournamentPlayed, tournamentPlayed > 0 { + HStack(alignment: .lastTextBaseline, spacing: 0) { + Text(tournamentPlayed.formatted()).font(.title3) + Text(" tournoi" + tournamentPlayed.pluralSuffix).font(.caption) } - if let rank = player.getRank() { - Text(rank.ordinalFormattedSuffix()).italic(player.isAssimilated) - .font(.caption) } } + .lineLimit(1) + .truncationMode(.tail) - if showProgression, player.getProgression() != 0 { + if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { Text("(") - Text(player.getProgression().formatted(.number.sign(strategy: .always()))) - .foregroundStyle(player.getProgressionColor(progression: player.getProgression())) - Text(")") - }.font(.title3) - } - - if let pts = player.getPoints(), pts > 0 { - HStack(alignment: .lastTextBaseline, spacing: 0) { - Text(pts.formatted()).font(.title3) - Text(" pts").font(.caption) + Text(assimilatedAsMaleRank.formatted()) + VStack(alignment: .leading, spacing: 0) { + Text("équivalence") + Text("messieurs") + } + .font(.caption) + Text(")").font(.title3) } } - - if let tournamentPlayed = player.tournamentPlayed, tournamentPlayed > 0 { - HStack(alignment: .lastTextBaseline, spacing: 0) { - Text(tournamentPlayed.formatted()).font(.title3) - Text(" tournoi" + tournamentPlayed.pluralSuffix).font(.caption) + + HStack { + Text(player.formattedLicense()) + if let computedAge = player.computedAge { + Text(computedAge.formatted() + " ans") } } - } - .lineLimit(1) - - if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { - HStack(alignment: .top, spacing: 2) { - Text("(") - Text(assimilatedAsMaleRank.formatted()) - VStack(alignment: .leading, spacing: 0) { - Text("équivalence") - Text("messieurs") - } - .font(.caption) - Text(")").font(.title3) + .font(.caption) + if let clubName = player.clubName { + Text(clubName) + .font(.caption) } - } - - HStack { - Text(player.formattedLicense()) - if let computedAge = player.computedAge { - Text(computedAge.formatted() + " ans") + if let ligueName = player.ligueName { + Text(ligueName) + .font(.caption) } } - .font(.caption) - if let clubName = player.clubName { - Text(clubName) - .font(.caption) - } - if let ligueName = player.ligueName { - Text(ligueName) - .font(.caption) - } } } } diff --git a/PadelClub/Views/Shared/SelectablePlayerListView.swift b/PadelClub/Views/Shared/SelectablePlayerListView.swift index 8ecd8cc..9b61575 100644 --- a/PadelClub/Views/Shared/SelectablePlayerListView.swift +++ b/PadelClub/Views/Shared/SelectablePlayerListView.swift @@ -34,7 +34,7 @@ struct SelectablePlayerListView: View { return URL.importDateFormatter.date(from: lastDataSource) } - init(allowSelection: Int = 0, searchField: String? = nil, dataSet: DataSet = .national, filterOption: PlayerFilterOption = .all, hideAssimilation: Bool = false, ascending: Bool = true, sortOption: SortOption = .rank, fromPlayer: FederalPlayer? = nil, codeClub: String? = nil, ligue: String? = nil, showFemaleInMaleAssimilation: Bool = false, tokens: [SearchToken] = [], hidePlayers: [String]? = nil, playerSelectionAction: PlayerSelectionAction? = nil, contentUnavailableAction: ContentUnavailableAction? = nil) { + init(allowSelection: Int = 0, isPresented: Bool = true, searchField: String? = nil, dataSet: DataSet = .national, filterOption: PlayerFilterOption = .all, hideAssimilation: Bool = false, ascending: Bool = true, sortOption: SortOption = .rank, fromPlayer: FederalPlayer? = nil, codeClub: String? = nil, ligue: String? = nil, showFemaleInMaleAssimilation: Bool = false, tokens: [SearchToken] = [], hidePlayers: [String]? = nil, playerSelectionAction: PlayerSelectionAction? = nil, contentUnavailableAction: ContentUnavailableAction? = nil) { self.allowSelection = allowSelection self.playerSelectionAction = playerSelectionAction self.contentUnavailableAction = contentUnavailableAction @@ -45,7 +45,7 @@ struct SelectablePlayerListView: View { searchViewModel.debouncableText = searchField ?? "" searchViewModel.showFemaleInMaleAssimilation = showFemaleInMaleAssimilation searchViewModel.searchText = searchField ?? "" - searchViewModel.isPresented = allowSelection != 0 + searchViewModel.isPresented = isPresented searchViewModel.allowSelection = allowSelection searchViewModel.codeClub = fromPlayer?.clubCode ?? codeClub searchViewModel.clubName = nil @@ -171,7 +171,8 @@ struct SelectablePlayerListView: View { } .scrollDismissesKeyboard(.immediately) .navigationBarBackButtonHidden(searchViewModel.allowMultipleSelection) - //.toolbarBackground(.visible, for: .bottomBar) + .toolbarBackground(searchViewModel.allowMultipleSelection ? .visible : .hidden, for: .bottomBar) + .toolbarBackground(.visible, for: .navigationBar) // .toolbarRole(searchViewModel.allowMultipleSelection ? .navigationStack : .editor) .interactiveDismissDisabled(searchViewModel.selectedPlayers.isEmpty == false) .navigationTitle(searchViewModel.label(forDataSet: searchViewModel.dataSet)) @@ -221,7 +222,7 @@ struct SelectablePlayerListView: View { if searchViewModel.selectedPlayers.isEmpty && searchViewModel.filterSelectionEnabled { searchViewModel.filterSelectionEnabled = false - } else { + } else if searchViewModel.allowSelection >= searchViewModel.selectedPlayers.count { searchViewModel.filterSelectionEnabled = true } } @@ -247,22 +248,21 @@ struct SelectablePlayerListView: View { } } - if searchViewModel.selectedPlayers.isEmpty == false { - ToolbarItem(placement: .topBarTrailing) { - ButtonValidateView { - if let playerSelectionAction { - playerSelectionAction(searchViewModel.selectedPlayers) - } - dismiss() + ToolbarItem(placement: .topBarTrailing) { + ButtonValidateView { + if let playerSelectionAction { + playerSelectionAction(searchViewModel.selectedPlayers) } + dismiss() } - ToolbarItem(placement: .status) { - let count = searchViewModel.selectedPlayers.count - VStack(spacing: 0) { - Text(count.formatted() + " joueur" + count.pluralSuffix + " séléctionné" + count.pluralSuffix).font(.footnote).foregroundStyle(.secondary) - FooterButtonView("\(searchViewModel.filterSelectionEnabled ? "masquer" : "voir") la liste") { - searchViewModel.filterSelectionEnabled.toggle() - } + .disabled(searchViewModel.selectedPlayers.isEmpty) + } + ToolbarItem(placement: .status) { + let count = searchViewModel.selectedPlayers.count + VStack(spacing: 0) { + Text(count.formatted() + " joueur" + count.pluralSuffix + " séléctionné" + count.pluralSuffix).font(.footnote).foregroundStyle(.secondary) + FooterButtonView("\(searchViewModel.filterSelectionEnabled ? "masquer" : "voir") la liste") { + searchViewModel.filterSelectionEnabled.toggle() } } } @@ -430,6 +430,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -540,6 +541,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -654,6 +656,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -763,6 +766,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -874,6 +878,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -972,6 +977,7 @@ struct MySearchView: View { } } .lineLimit(1) + .truncationMode(.tail) if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { HStack(alignment: .top, spacing: 2) { @@ -1033,7 +1039,7 @@ struct MySearchView: View { Text(searchViewModel.contentUnavailableMessage) } actions: { - RowButtonView("Lancer une nouvelle recherche") { + RowButtonView("Nouvelle recherche") { searchViewModel.debouncableText = "" } .padding() diff --git a/PadelClub/Views/Shared/TournamentFilterView.swift b/PadelClub/Views/Shared/TournamentFilterView.swift index 8e84a0b..6af4d7c 100644 --- a/PadelClub/Views/Shared/TournamentFilterView.swift +++ b/PadelClub/Views/Shared/TournamentFilterView.swift @@ -63,7 +63,7 @@ struct TournamentFilterView: View { } } } label: { - Text(level.localizedLabel(.title)) + Text(level.localizedLevelLabel(.title)) } } } header: { diff --git a/PadelClub/Views/Team/Components/TeamHeaderView.swift b/PadelClub/Views/Team/Components/TeamHeaderView.swift index 6e896a1..4109b18 100644 --- a/PadelClub/Views/Team/Components/TeamHeaderView.swift +++ b/PadelClub/Views/Team/Components/TeamHeaderView.swift @@ -45,7 +45,7 @@ struct TeamHeaderView: View { let positionLabel = team.positionLabel() let cutLabel = tournament.cutLabel(index: teamIndex, teamCount: teamCount) if team.isWildCard() { - Text("wildcard").font(.caption).italic() + Text("wildcard").foregroundStyle(.red).font(.caption).italic() Text(positionLabel ?? cutLabel) } else { if let positionLabel { diff --git a/PadelClub/Views/Team/Components/TeamWeightView.swift b/PadelClub/Views/Team/Components/TeamWeightView.swift index e74226c..f19b6e6 100644 --- a/PadelClub/Views/Team/Components/TeamWeightView.swift +++ b/PadelClub/Views/Team/Components/TeamWeightView.swift @@ -8,17 +8,16 @@ import SwiftUI struct TeamWeightView: View { - var team: TeamRegistration + @EnvironmentObject var dataStore: DataStore + let team: TeamRegistration var teamPosition: TeamPosition? = nil - var teamIndex: Int? - var displayWeight: Bool = true + + var teamIndex: Int? { + team.tournamentObject()?.indexOf(team: team) + } - init(team: TeamRegistration, teamPosition: TeamPosition? = nil) { - self.team = team - self.teamPosition = teamPosition - let tournament = team.tournamentObject() - self.teamIndex = tournament?.indexOf(team: team) - self.displayWeight = tournament?.hideWeight() == false + var displayWeight: Bool { + team.tournamentObject()?.hideWeight() == false } var body: some View { diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index 7e5f4be..bd32b32 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -21,7 +21,8 @@ struct EditingTeamView: View { @State private var registrationDate : Date @State private var callDate : Date @State private var name: String - + @FocusState private var focusedField: TeamRegistration.CodingKeys? + var messageSentFailed: Binding { Binding { sentError != nil @@ -137,9 +138,12 @@ struct EditingTeamView: View { })) { Text("Forfait") } - + } + + Section { TextField("Nom de l'équipe", text: $name) .autocorrectionDisabled() + .focused($focusedField, equals: ._name) .keyboardType(.alphabet) .frame(maxWidth: .infinity) .submitLabel(.done) @@ -153,6 +157,8 @@ struct EditingTeamView: View { _save() } + } header: { + Text("Nom de l'équipe") } Section { @@ -182,6 +188,16 @@ struct EditingTeamView: View { } } } + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .alert("Un problème est survenu", isPresented: messageSentFailed) { Button("OK") { } @@ -200,8 +216,6 @@ struct EditingTeamView: View { case .failed: self.sentError = .messageFailed case .sent: - let uncalledTeams = team.getPhoneNumbers().isEmpty - if networkMonitor.connected == false { self.contactType = nil if team.getPhoneNumbers().isEmpty == false { @@ -232,8 +246,6 @@ struct EditingTeamView: View { self.contactType = nil self.sentError = .mailFailed case .sent: - let uncalledTeams = team.getMail().isEmpty - if networkMonitor.connected == false { self.contactType = nil if team.getMail().isEmpty == false { diff --git a/PadelClub/Views/Team/TeamRowView.swift b/PadelClub/Views/Team/TeamRowView.swift index 533d4bc..1e11da3 100644 --- a/PadelClub/Views/Team/TeamRowView.swift +++ b/PadelClub/Views/Team/TeamRowView.swift @@ -8,6 +8,7 @@ import SwiftUI struct TeamRowView: View { + @EnvironmentObject var dataStore: DataStore var team: TeamRegistration var teamPosition: TeamPosition? = nil var displayCallDate: Bool = false @@ -17,28 +18,37 @@ struct TeamRowView: View { TeamWeightView(team: team, teamPosition: teamPosition) } label: { VStack(alignment: .leading) { - if let name = team.name { - Text(name).foregroundStyle(.secondary) - } - - if let groupStage = team.groupStageObject() { - HStack { - Text(groupStage.groupStageTitle()) - if let finalPosition = groupStage.finalPosition(ofTeam: team) { - Text((finalPosition + 1).ordinalFormatted()) + HStack { + if let groupStage = team.groupStageObject() { + HStack { + Text(groupStage.groupStageTitle(.title)) + if let finalPosition = groupStage.finalPosition(ofTeam: team) { + Text((finalPosition + 1).ordinalFormatted()) + } } + } else if let round = team.initialRound() { + Text(round.roundTitle(.wide)) + } + + if team.isWildCard() { + Text("wildcard").italic().foregroundStyle(.red).font(.caption) } - } else if let round = team.initialRound() { - Text(round.roundTitle(.wide)) } - if team.players().isEmpty == false { - ForEach(team.players()) { player in - Text(player.playerLabel()) + if let name = team.name { + Text(name).font(.title3) + if team.players().isEmpty { + Text("Aucun joueur") } } else { - Text("Place réservée") - Text("Place réservée") + if team.players().isEmpty == false { + ForEach(team.players()) { player in + Text(player.playerLabel()) + } + } else { + Text("Place réservée") + Text("Place réservée") + } } } if displayCallDate { diff --git a/PadelClub/Views/Tournament/FileImportView.swift b/PadelClub/Views/Tournament/FileImportView.swift index c6644ff..99bf6b9 100644 --- a/PadelClub/Views/Tournament/FileImportView.swift +++ b/PadelClub/Views/Tournament/FileImportView.swift @@ -85,11 +85,18 @@ struct FileImportView: View { @State private var presentFormatHelperView: Bool = false @State private var validatedTournamentIds: Set = Set() + init(defaultFileProvider: FileImportManager.FileProvider = .frenchFederation) { + _fileProvider = .init(wrappedValue: defaultFileProvider) + } + var tournamentStore: TournamentStore { return self.tournament.tournamentStore } private func filteredTeams(tournament: Tournament) -> [FileImportManager.TeamHolder] { + if tournament.isAnimation() { + return teams.sorted(by: \.weight) + } return teams.filter { $0.tournamentCategory == tournament.tournamentCategory && $0.tournamentAgeCategory == tournament.federalTournamentAge }.sorted(by: \.weight) } @@ -309,10 +316,14 @@ struct FileImportView: View { LabeledContent { Text(_filteredTeams.count.formatted()) } label: { - Text("Équipe\(_filteredTeams.count.pluralSuffix) \(tournament.tournamentCategory.importingRawValue) \(tournament.federalTournamentAge.importingRawValue) détectée\(_filteredTeams.count.pluralSuffix)") + if tournament.isAnimation() { + Text("Équipe\(_filteredTeams.count.pluralSuffix) détectée\(_filteredTeams.count.pluralSuffix)") + } else { + Text("Équipe\(_filteredTeams.count.pluralSuffix) \(tournament.tournamentCategory.importingRawValue) \(tournament.federalTournamentAge.importingRawValue.lowercased()) détectée\(_filteredTeams.count.pluralSuffix)") + } } } footer: { - if previousTeams.isEmpty == false { + if previousTeams.isEmpty == false, tournament.isAnimation() == false { Text("La liste ci-dessous n'est qu'une indication d'évolution par rapport au seul poids d'équipe. Cela ne tient pas compte des dates d'inscriptions, WCs et autres éléments.").foregroundStyle(.logoRed) } } @@ -535,6 +546,9 @@ struct FileImportView: View { Section { HStack { VStack(alignment: .leading) { + if let teamName = team.name { + Text(teamName).foregroundStyle(.secondary) + } ForEach(team.players.sorted(by: \.computedRank)) { Text($0.playerLabel()) } diff --git a/PadelClub/Views/Tournament/Screen/AddTeamView.swift b/PadelClub/Views/Tournament/Screen/AddTeamView.swift index 32b0f1a..71aebc0 100644 --- a/PadelClub/Views/Tournament/Screen/AddTeamView.swift +++ b/PadelClub/Views/Tournament/Screen/AddTeamView.swift @@ -7,16 +7,15 @@ import SwiftUI import LeStorage +import CoreData struct AddTeamView: View { @EnvironmentObject var dataStore: DataStore @Environment(\.dismiss) var dismiss - @FetchRequest( - sortDescriptors: [], - animation: .default) - private var fetchPlayers: FetchedResults + private var fetchRequest: FetchRequest + private var fetchPlayers: FetchedResults { fetchRequest.wrappedValue } var tournament: Tournament var cancelShouldDismiss: Bool = false @@ -41,7 +40,13 @@ struct AddTeamView: View { @State private var homonyms: [PlayerRegistration] = [] @State private var confirmHomonym: Bool = false @State private var editableTextField: String = "" - + @State private var textHeight: CGFloat = 100 // Default height + @State private var hitsForSearch: [Int: Int] = [:] + @State private var searchForHit: Int = 0 + @State private var displayWarningNotEnoughCharacter: Bool = false + @State private var testMessageIndex: Int = 0 + @State private var presentLocalMultiplayerSearch: Bool = false + var tournamentStore: TournamentStore { return self.tournament.tournamentStore } @@ -62,17 +67,23 @@ struct AddTeamView: View { _createdPlayerIds = .init(wrappedValue: createdPlayerIds) } + let request: NSFetchRequest = ImportedPlayer.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)] + request.fetchLimit = 1000 if let pasteString { _pasteString = .init(wrappedValue: pasteString) - _fetchPlayers = FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)], predicate: SearchViewModel.pastePredicate(pasteField: pasteString, mostRecentDate: tournament.rankSourceDate, filterOption: tournament.tournamentCategory.playerFilterOption)) + request.predicate = SearchViewModel.pastePredicate(pasteField: pasteString, mostRecentDate: tournament.rankSourceDate, filterOption: tournament.tournamentCategory.playerFilterOption) _autoSelect = .init(wrappedValue: true) _editableTextField = .init(wrappedValue: pasteString) + _textHeight = .init(wrappedValue: Self._calculateHeight(text: pasteString)) cancelShouldDismiss = true } + + fetchRequest = FetchRequest(fetchRequest: request, animation: .default) } var body: some View { - if pasteString != nil, fetchPlayers.isEmpty == false { + if let pasteString, pasteString.isEmpty == false, fetchPlayers.isEmpty == false { computedBody .searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher dans les résultats")) } else { @@ -84,14 +95,27 @@ struct AddTeamView: View { List(selection: $createdPlayerIds) { _buildingTeamView() } - .onReceive(fetchPlayers.publisher.count()) { _ in // <-- here - if let pasteString, count == 2, autoSelect == true { - fetchPlayers.filter { $0.hitForSearch(pasteString) >= hitTarget }.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }).forEach { player in + .onReceive(fetchPlayers.publisher.count()) { receivedCount in // <-- here + if let pasteString, pasteString.isEmpty == false, count == 2, autoSelect == true { + fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }).forEach { player in createdPlayerIds.insert(player.license!) } autoSelect = false } } + .overlay(alignment: .bottom) { + if displayWarningNotEnoughCharacter { + Text("2 lettres mininum") + .toastFormatted() + .animation(.easeInOut(duration: 2.0), value: displayWarningNotEnoughCharacter) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + displayWarningNotEnoughCharacter = false + } + } + } + } + .alert("Présence d'homonyme", isPresented: $confirmHomonym) { Button("Créer l'équipe quand même") { _createTeam(checkDuplicates: false, checkHomonym: false) @@ -117,28 +141,55 @@ struct AddTeamView: View { } message: { Text("Cette équipe existe déjà dans votre liste d'inscription.") } - .sheet(isPresented: $presentPlayerSearch, onDismiss: { - selectionSearchField = nil - }) { + .sheet(isPresented: $presentLocalMultiplayerSearch) { NavigationStack { - SelectablePlayerListView(allowSelection: -1, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in - selectionSearchField = nil + SelectablePlayerListView(allowSelection: -1, isPresented: false, searchField: searchField, dataSet: .club, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in players.forEach { player in let newPlayer = PlayerRegistration(importedPlayer: player) newPlayer.setComputedRank(in: tournament) + createdPlayers = Set() + createdPlayerIds = Set() createdPlayers.insert(newPlayer) createdPlayerIds.insert(newPlayer.id) + _createTeam(checkDuplicates: false, checkHomonym: false) } + } contentUnavailableAction: { searchViewModel in + presentLocalMultiplayerSearch = false selectionSearchField = searchViewModel.searchText + } + } + .tint(.master) + + } + .sheet(isPresented: $presentPlayerSearch) { + NavigationStack { + SelectablePlayerListView(allowSelection: 2 - _currentSelectionIds().count, isPresented: true, searchField: searchField, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in + players.forEach { player in + let newPlayer = PlayerRegistration(importedPlayer: player) + newPlayer.setComputedRank(in: tournament) + createdPlayers.insert(newPlayer) + createdPlayerIds.insert(newPlayer.id) + } + } contentUnavailableAction: { searchViewModel in presentPlayerSearch = false - presentPlayerCreation = true + selectionSearchField = searchViewModel.searchText } } .tint(.master) } .sheet(isPresented: $presentPlayerCreation) { - PlayerPopoverView(source: _searchSource(), sex: _addPlayerSex()) { p in + PlayerPopoverView(sex: _addPlayerSex()) { p in + p.setComputedRank(in: tournament) + createdPlayers.insert(p) + createdPlayerIds.insert(p.id) + } + .tint(.master) + } + .sheet(item: $selectionSearchField, onDismiss: { + selectionSearchField = nil + }) { selectionSearchField in + PlayerPopoverView(source: selectionSearchField, sex: _addPlayerSex()) { p in p.setComputedRank(in: tournament) createdPlayers.insert(p) createdPlayerIds.insert(p.id) @@ -155,7 +206,7 @@ struct AddTeamView: View { if pasteString == nil { ToolbarItem(placement: .bottomBar) { PasteButton(payloadType: String.self) { strings in - guard let first = strings.first else { return } + let first = strings.first ?? "" handlePasteString(first) } .foregroundStyle(.master) @@ -163,10 +214,30 @@ struct AddTeamView: View { .buttonBorderShape(.capsule) } } + + ToolbarItem(placement: .topBarTrailing) { + Button { + let generalString = UIPasteboard.general.string ?? "" + + #if targetEnvironment(simulator) + let s = testMessages[testMessageIndex % testMessages.count] + handlePasteString(s) + testMessageIndex += 1 + #else + handlePasteString(generalString) + #endif + } label: { + Label("Coller", systemImage: "doc.on.clipboard").labelStyle(.iconOnly) + } + .foregroundStyle(.master) + .labelStyle(.iconOnly) + .buttonBorderShape(.capsule) + } + } .navigationBarBackButtonHidden(true) .toolbarBackground(.visible, for: .navigationBar) - .toolbarBackground(.visible, for: .bottomBar) + .toolbarBackground(.automatic, for: .bottomBar) .navigationBarTitleDisplayMode(.inline) .navigationTitle(editedTeam == nil ? "Ajouter une équipe" : "Modifier l'équipe") .environment(\.editMode, Binding.constant(EditMode.active)) @@ -192,9 +263,23 @@ struct AddTeamView: View { } } + if tournament.isAnimation(), createdPlayers.isEmpty == true { + Section { + RowButtonView("Ajouter plusieurs joueurs du club") { + presentLocalMultiplayerSearch = true + } + } footer: { + Text("Crée une équipe par joueur sélectionné") + } + } + Section { RowButtonView("Créer un non classé / non licencié") { - presentPlayerCreation = true + if let pasteString, pasteString.isEmpty == false { + selectionSearchField = pasteString + } else { + presentPlayerCreation = true + } } } footer: { Text("Si le joueur n'a pas encore de licence ou n'a pas encore participé à une compétition, vous pouvez le créer vous-même.") @@ -216,11 +301,7 @@ struct AddTeamView: View { private func _filterOption() -> PlayerFilterOption { return tournament.tournamentCategory.playerFilterOption } - - private func _searchSource() -> String? { - selectionSearchField ?? pasteString - } - + private func _currentSelection() -> Set { var currentSelection = Set() createdPlayerIds.compactMap { id in @@ -256,6 +337,7 @@ struct AddTeamView: View { } private func _isDuplicate() -> Bool { + if tournament.isAnimation() { return false } let ids : [String?] = _currentSelectionIds() if tournament.selectedSortedTeams().anySatisfy({ $0.containsExactlyPlayerLicenses(ids) }) { return true @@ -291,14 +373,16 @@ struct AddTeamView: View { Logger.error(error) } - createdPlayers.removeAll() - createdPlayerIds.removeAll() - pasteString = nil - editableTextField = "" + pasteString = nil + editableTextField = "" - if team.players().count > 1 { - dismiss() - } + if team.players().count > 1 { + createdPlayers.removeAll() + createdPlayerIds.removeAll() + dismiss() + } else { + editedTeam = team + } } private func _updateTeam(checkDuplicates: Bool) { @@ -320,23 +404,37 @@ struct AddTeamView: View { } catch { Logger.error(error) } - createdPlayers.removeAll() - createdPlayerIds.removeAll() + pasteString = nil editableTextField = "" - self.editedTeam = nil if editedTeam.players().count > 1 { dismiss() } } + // Calculating the height based on the content of the TextEditor + static private func _calculateHeight(text: String) -> CGFloat { + let size = CGSize(width: UIScreen.main.bounds.width - 32, height: .infinity) + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 17)] + let boundingRect = text.boundingRect( + with: size, + options: .usesLineFragmentOrigin, + attributes: attributes, + context: nil + ) + return max(boundingRect.height + 20, 40) // Add some padding and set a minimum height + } + @ViewBuilder private func _buildingTeamView() -> some View { if let pasteString { Section { TextEditor(text: $editableTextField) - .frame(minHeight: 120, maxHeight: .infinity) + .frame(height: textHeight) + .onChange(of: editableTextField) { + textHeight = Self._calculateHeight(text: pasteString) + } .focused($focusedField, equals: .pasteField) .toolbar { ToolbarItemGroup(placement: .keyboard) { @@ -346,8 +444,12 @@ struct AddTeamView: View { } Spacer() Button("Chercher") { - self.handlePasteString(editableTextField) - self.focusedField = nil + if editableTextField.count > 1 { + self.handlePasteString(editableTextField) + self.focusedField = nil + } else { + self.displayWarningNotEnoughCharacter = true + } } .buttonStyle(.bordered) } @@ -360,12 +462,10 @@ struct AddTeamView: View { self.focusedField = .pasteField } Spacer() - FooterButtonView("effacer", role: .destructive) { + FooterButtonView("effacer le texte") { self.focusedField = nil self.editableTextField = "" self.pasteString = nil - self.createdPlayers.removeAll() - self.createdPlayerIds.removeAll() } } } @@ -375,17 +475,30 @@ struct AddTeamView: View { ForEach(createdPlayerIds.sorted(), id: \.self) { id in if let p = createdPlayers.first(where: { $0.id == id }) { VStack(alignment: .leading, spacing: 0) { - if let player = unsortedPlayers.first(where: { $0.licenceId == p.licenceId }), editedTeam?.includes(player: player) == false { + if let player = unsortedPlayers.first(where: { ($0.licenceId == p.licenceId && $0.licenceId != nil) }), editedTeam?.includes(player: player) == false { Text("Déjà inscrit !").foregroundStyle(.logoRed).bold() } + if tournament.isPlayerAgeInadequate(player: p) { + Text("Âge invalide !").foregroundStyle(.logoRed).bold() + } + if tournament.isPlayerRankInadequate(player: p) { + Text("Trop bien classé !").foregroundStyle(.logoRed).bold() + } PlayerView(player: p).tag(p.id) + .environment(tournament) } } if let p = fetchPlayers.first(where: { $0.license == id }) { VStack(alignment: .leading, spacing: 0) { - if unsortedPlayers.first(where: { $0.licenceId == p.license }) != nil { + if let pasteString, pasteString.isEmpty == false, unsortedPlayers.first(where: { $0.licenceId == p.license }) != nil { Text("Déjà inscrit !").foregroundStyle(.logoRed).bold() } + if tournament.isPlayerAgeInadequate(player: p) { + Text("Âge invalide !").foregroundStyle(.logoRed).bold() + } + if tournament.isPlayerRankInadequate(player: p) { + Text("Trop bien classé !").foregroundStyle(.logoRed).bold() + } ImportedPlayerView(player: p).tag(p.license!) } } @@ -403,7 +516,7 @@ struct AddTeamView: View { } else { RowButtonView("Confirmer") { _updateTeam(checkDuplicates: false) - editedTeam = nil + dismiss() } } } header: { @@ -436,17 +549,23 @@ struct AddTeamView: View { } - if let pasteString { - if fetchPlayers.isEmpty { + if let pasteString, pasteString.isEmpty == false { + let sortedPlayers = _searchFilteredPlayers() + + if sortedPlayers.isEmpty { ContentUnavailableView { Label("Aucun résultat", systemImage: "person.2.slash") } description: { Text("Aucun joueur classé n'a été trouvé dans ce message. Attention, si un joueur n'a pas joué de tournoi dans les 12 derniers, Padel Club ne pourra pas le trouver.") } actions: { RowButtonView("Créer un joueur non classé") { - presentPlayerCreation = true + selectionSearchField = pasteString } - + + RowButtonView("Chercher dans la base") { + presentPlayerSearch = true + } + RowButtonView("Effacer cette recherche") { self.pasteString = nil self.editableTextField = "" @@ -454,20 +573,44 @@ struct AddTeamView: View { } } else { - _listOfPlayers(pasteString: pasteString) + _listOfPlayers(searchFilteredPlayers: sortedPlayers, pasteString: pasteString) } } else { _managementView() } } + @MainActor + func hitForSearch(_ ip: ImportedPlayer, _ pasteString: String?) -> Int { + guard let pasteString else { return 0 } + let _searchForHit = pasteString.hashValue + + if searchForHit != _searchForHit { + DispatchQueue.main.async { + searchForHit = _searchForHit + hitsForSearch = [:] + } + } + + let value = hitsForSearch[ip.id.hashValue] + if let value { + return value + } else { + let hit = ip.hitForSearch(pasteString) + DispatchQueue.main.async { + hitsForSearch[ip.id.hashValue] = hit + } + return hit + } + } + private var count: Int { - return fetchPlayers.filter { $0.hitForSearch(pasteString ?? "") >= hitTarget }.count + return fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.count } private var hitTarget: Int { if (pasteString?.matches(of: /[1-9][0-9]{5,7}/).count ?? 0) > 1 { - if fetchPlayers.filter({ $0.hitForSearch(pasteString ?? "") == 100 }).count == 2 { return 100 } + if fetchPlayers.filter({ hitForSearch($0, pasteString) == 100 }).count == 2 { return 100 } } else { return 2 } @@ -482,31 +625,74 @@ struct AddTeamView: View { } } + @MainActor private func handlePasteString(_ first: String) { - Task { - await MainActor.run { + if first.isEmpty == false { + DispatchQueue.main.async { fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption()) fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)] - pasteString = first - editableTextField = first autoSelect = true } } - + pasteString = first + editableTextField = first + textHeight = Self._calculateHeight(text: first) } - @ViewBuilder - private func _listOfPlayers(pasteString: String) -> some View { - let sortedPlayers = fetchPlayers.filter({ $0.contains(searchField) || searchField.isEmpty }).sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }) + private func _listOfPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> some View { + let sortedPlayers = _sortedPlayers(searchFilteredPlayers: searchFilteredPlayers, pasteString: pasteString) Section { ForEach(sortedPlayers) { player in ImportedPlayerView(player: player).tag(player.license!) + //Text(player.getLastName() + " " + player.getFirstName()).tag(player.license!) } } header: { Text(sortedPlayers.count.formatted() + " résultat" + sortedPlayers.count.pluralSuffix) } } + + private func _searchFilteredPlayers() -> [ImportedPlayer] { + if searchField.isEmpty { + return Array(fetchPlayers) + } else { + return fetchPlayers.filter({ $0.contains(searchField) }) + } + } + + private func _sortedPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> [ImportedPlayer] { + return searchFilteredPlayers.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }) + } } + +let testMessages = [ + "Anthony dovetta ( 3620578 K )et christophe capeau ( 4666443v)", +""" +ok merci, il s'agit de : +Olivier Seguin - licence 5033439 +JPascal Bondierlange - licence : +6508359 С +Cordialement +""", +""" +Bonsoir Lise, peux tu nous inscrire pour le 250 hommes du 15 au 17 novembre ? +Paires DESCHAMPS/PARDO. En te remerciant. Bonne soirée +Franck +""", +""" +Coucou inscription pour le tournoi du 11 / +12 octobre +Dumoutier/ Liagre Charlotte +Merci de ta confirmation" +""", +""" +Anthony Contet 6081758f +Tullou Benjamin 8990867f +""", +""" +Sms Julien La Croix +33622886688 +Salut Raz, c'est ! Ju Lacroix J'espère que tu vas bien depuis le temps! Est-ce que tu peux nous inscrire au 1000 de Bandol avec Derek Gerson stp? +""" +] diff --git a/PadelClub/Views/Tournament/Screen/BroadcastView.swift b/PadelClub/Views/Tournament/Screen/BroadcastView.swift index 661f69e..d687e29 100644 --- a/PadelClub/Views/Tournament/Screen/BroadcastView.swift +++ b/PadelClub/Views/Tournament/Screen/BroadcastView.swift @@ -42,7 +42,7 @@ struct BroadcastView: View { navigation.selectedTab = .umpire } - RowButtonView("Jeter un oeil au site Padel Club") { + RowButtonView("Voir le site Padel Club") { UIApplication.shared.open(URLs.main.url) } } @@ -104,12 +104,12 @@ struct BroadcastView: View { Section { Toggle(isOn: $tournament.isPrivate) { Text("Tournoi privé") - if (tournament.isPrivate && Guard.main.purchasedTransactions.isEmpty) { - Text("Vous devez disposer d'une offre pour rendre publique ce tournoi.") - .foregroundStyle(.logoRed) - } } - .disabled(_disablePrivateToggle()) + + Toggle(isOn: $tournament.hideTeamsWeight) { + Text("Masquer les poids des équipes") + } + } footer: { let verb : String = tournament.isPrivate ? "est" : "sera" let footerString = " Le tournoi \(verb) masqué sur le site [Padel Club](\(URLs.main.rawValue))" @@ -160,10 +160,6 @@ struct BroadcastView: View { Text("Publication prévue") } } - - Toggle(isOn: $tournament.hideTeamsWeight) { - Text("Masquer les poids des équipes") - } } header: { Text("Liste des équipes") } footer: { @@ -273,32 +269,34 @@ struct BroadcastView: View { } } .toolbar(content: { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Section { - let links : [PageLink] = [.teams, .summons, .groupStages, .matches, .rankings, .broadcast, .clubBroadcast] - Picker(selection: $pageLink) { - ForEach(links) { pageLink in - Text(pageLink.localizedLabel()).tag(pageLink) + if StoreCenter.main.userId != nil, tournament.isPrivate == false, tournament.club() != nil { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Section { + let links : [PageLink] = [.teams, .summons, .groupStages, .matches, .rankings, .broadcast, .clubBroadcast] + Picker(selection: $pageLink) { + ForEach(links) { pageLink in + Text(pageLink.localizedLabel()).tag(pageLink) + } + } label: { + Text("Choisir la page à partager") } - } label: { - Text("Choisir la page à partager") + .pickerStyle(.menu) + actionForURL(title: "Partager la page '" + pageLink.localizedLabel() + "'", url: tournament.shareURL(pageLink)) + } header: { + Text("Lien du tournoi à partager") } - .pickerStyle(.menu) - actionForURL(title: "Partager la page '" + pageLink.localizedLabel() + "'", url: tournament.shareURL(pageLink)) - } header: { - Text("Lien du tournoi à partager") - } - - Section { - let club = tournament.club() - actionForURL(title: (club == nil) ? "Aucun club indiqué pour ce tournoi" : club!.clubTitle(), description: "Page du club", url: club?.shareURL()) - actionForURL(title: "Padel Club", url: URLs.main.url) - } header: { - Text("Autres liens") + + Section { + let club = tournament.club() + actionForURL(title: (club == nil) ? "Aucun club indiqué pour ce tournoi" : club!.clubTitle(), description: "Page du club", url: club?.shareURL()) + actionForURL(title: "Padel Club", url: URLs.main.url) + } header: { + Text("Autres liens") + } + } label: { + Label("Partager les liens", systemImage: "square.and.arrow.up") } - } label: { - Label("Partager les liens", systemImage: "square.and.arrow.up") } } }) @@ -320,15 +318,7 @@ struct BroadcastView: View { _save() } } - - private func _disablePrivateToggle() -> Bool { - #if DEBUG - return false - #else - return (tournament.isPrivate && Guard.main.purchasedTransactions.isEmpty) - #endif - } - + private func _save() { do { if [tournament.publishTeams, tournament.publishSummons, tournament.publishBrackets, tournament.publishGroupStages].anySatisfy({ $0 == true }) { diff --git a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift index 2114d81..4161e47 100644 --- a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift @@ -18,6 +18,7 @@ struct InscriptionInfoView: View { @State private var duplicates : [PlayerRegistration] = [] @State private var problematicPlayers : [PlayerRegistration] = [] @State private var inadequatePlayers : [PlayerRegistration] = [] + @State private var ageInadequatePlayers : [PlayerRegistration] = [] @State private var playersWithoutValidLicense : [PlayerRegistration] = [] @State private var entriesFromBeachPadel : [TeamRegistration] = [] @State private var playersMissing : [TeamRegistration] = [] @@ -177,6 +178,23 @@ struct InscriptionInfoView: View { Text("Il s'agit des joueurs ou joueuses dont le rang est inférieur à la limite fédérale.") } + Section { + DisclosureGroup { + ForEach(ageInadequatePlayers) { player in + ImportedPlayerView(player: player) + } + } label: { + LabeledContent { + Text(ageInadequatePlayers.count.formatted()) + } label: { + Text("Joueurs trop jeunes ou trop âgés") + } + } + .listRowView(color: .logoRed) + } footer: { + Text("Il s'agit des joueurs ou joueuses dont l'âge sportif est inférieur ou supérieur à la limite fédérale.") + } + Section { DisclosureGroup { ForEach(playersWithoutValidLicense) { @@ -228,6 +246,7 @@ struct InscriptionInfoView: View { homonyms = tournament.homonyms(in: players) problematicPlayers = players.filter({ $0.sex == nil }) inadequatePlayers = tournament.inadequatePlayers(in: players) + ageInadequatePlayers = tournament.ageInadequatePlayers(in: players) playersWithoutValidLicense = tournament.playersWithoutValidLicense(in: players) entriesFromBeachPadel = tournament.unsortedTeams().filter({ $0.isImported() }) playersMissing = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift index 35afb65..f7096d1 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift @@ -14,10 +14,15 @@ struct TournamentGeneralSettingsView: View { @Bindable var tournament: Tournament @State private var tournamentName: String = "" @State private var entryFee: Double? = nil + @State private var confirmationRequired: Bool = false + @State private var presentConfirmation: Bool = false + @State private var loserBracketMode: LoserBracketMode @FocusState private var focusedField: Tournament.CodingKeys? - + let priceTags: [Double] = [15.0, 20.0, 25.0] + init(tournament: Tournament) { self.tournament = tournament + _loserBracketMode = .init(wrappedValue: tournament.loserBracketMode) _tournamentName = State(wrappedValue: tournament.name ?? "") _entryFee = State(wrappedValue: tournament.entryFee) } @@ -25,9 +30,31 @@ struct TournamentGeneralSettingsView: View { var body: some View { @Bindable var tournament = tournament Form { + + Section { + TextField("Nom du tournoi", text: $tournamentName, axis: .vertical) + .lineLimit(2) + .frame(maxWidth: .infinity) + .keyboardType(.alphabet) + .focused($focusedField, equals: ._name) + } header: { + Text("Nom du tournoi") + } + Section { TournamentDatePickerView() TournamentDurationManagerView() + LabeledContent { + TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.current.currency?.identifier ?? "EUR")) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._entryFee) + } label: { + Text("Inscription") + } + } footer: { + Text("Si vous souhaitez que Padel Club vous aide à suivre les encaissements, indiquer un prix d'inscription. Sinon Padel Club vous aidera à suivre simplement l'arrivée et la présence des joueurs.") } Section { @@ -35,67 +62,86 @@ struct TournamentGeneralSettingsView: View { } Section { - Picker(selection: $tournament.loserBracketMode) { + Picker(selection: $loserBracketMode) { ForEach(LoserBracketMode.allCases) { Text($0.localizedLoserBracketMode()).tag($0) } } label: { Text("Position des perdants") } - .onChange(of: tournament.loserBracketMode) { - - _save() - - let rounds = tournament.rounds() - rounds.forEach { round in - round.loserBracketMode = tournament.loserBracketMode - } - - do { - try self.tournament.tournamentStore.rounds.addOrUpdate(contentOfs: rounds) - } catch { - Logger.error(error) + .onChange(of: loserBracketMode) { + if tournament.allLoserRoundMatches().anySatisfy({ $0.hasEnded() }) == false { + _refreshLoserBracketMode() + } else { + confirmationRequired = true } } } header: { Text("Matchs de classement") } footer: { - if dataStore.user.loserBracketMode != tournament.loserBracketMode { - _footerView() + if confirmationRequired == false { + if dataStore.user.loserBracketMode != tournament.loserBracketMode { + _footerView() + .onTapGesture(perform: { + self.dataStore.user.loserBracketMode = tournament.loserBracketMode + self.dataStore.saveUser() + }) + } else { + Text(tournament.loserBracketMode.localizedLoserBracketModeDescription()) + } + } else { + _footerViewConfirmationRequired() .onTapGesture(perform: { - self.dataStore.user.loserBracketMode = tournament.loserBracketMode - self.dataStore.saveUser() + presentConfirmation = true }) - } else { - Text(tournament.loserBracketMode.localizedLoserBracketModeDescription()) } } - - Section { - LabeledContent { - TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.current.currency?.identifier ?? "EUR")) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: .infinity) - .focused($focusedField, equals: ._entryFee) - } label: { - Text("Inscription") - } + } + .confirmationDialog("Attention", isPresented: $presentConfirmation, actions: { + Button("Confirmer", role: .destructive) { + _refreshLoserBracketMode() + confirmationRequired = false } - - Section { - TextField("Nom du tournoi", text: $tournamentName, axis: .vertical) - .lineLimit(2) - .frame(maxWidth: .infinity) - .keyboardType(.alphabet) - .focused($focusedField, equals: ._name) + Button("Annuler", role: .cancel) { + loserBracketMode = tournament.loserBracketMode } - } + + }) + .navigationBarBackButtonHidden(focusedField != nil) + .toolbar(content: { + if focusedField != nil { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + focusedField = nil + } + } + } + }) .toolbarBackground(.visible, for: .navigationBar) .toolbar { if focusedField != nil { ToolbarItem(placement: .keyboard) { HStack { + if focusedField == ._entryFee { + if tournament.isFree() { + ForEach(priceTags, id: \.self) { priceTag in + Button(priceTag.formatted(.currency(code: "EUR"))) { + entryFee = priceTag + tournament.entryFee = priceTag + focusedField = nil + } + .buttonStyle(.bordered) + } + } else { + Button("Gratuit") { + entryFee = nil + tournament.entryFee = nil + focusedField = nil + } + .buttonStyle(.bordered) + + } + } Spacer() Button("Valider") { if focusedField == ._name { @@ -156,9 +202,50 @@ struct TournamentGeneralSettingsView: View { } } + private func _refreshLoserBracketMode() { + tournament.loserBracketMode = loserBracketMode + _save() + + let rounds = tournament.rounds() + rounds.forEach { round in + let matches = round.loserRoundsAndChildren().flatMap({ $0._matches() }) + matches.forEach { match in + match.resetTeamScores(outsideOf: []) + match.resetMatch() + match.confirmed = false + } + + round.loserBracketMode = tournament.loserBracketMode + + if loserBracketMode == .automatic { + matches.forEach { match in + match.updateTeamScores() + } + } + + do { + try self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: matches) + } catch { + Logger.error(error) + } + } + + do { + try self.tournament.tournamentStore.rounds.addOrUpdate(contentOfs: rounds) + } catch { + Logger.error(error) + } + } + private func _footerView() -> some View { Text(tournament.loserBracketMode.localizedLoserBracketModeDescription()) + Text(" Modifier le réglage par défaut pour tous vos tournois").foregroundStyle(.blue) } + + private func _footerViewConfirmationRequired() -> some View { + Text("Au moins un match de classement est terminé, en modifiant ce réglage, les résultats de ces matchs de classement seront perdus.") + + + Text(" Modifier quand même ?").foregroundStyle(.red) + } } diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift index 5550b66..bc81b41 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift @@ -15,13 +15,22 @@ struct TournamentLevelPickerView: View { Picker(selection: $tournament.tournamentLevel, label: Text("Niveau")) { ForEach(TournamentLevel.allCases) { type in - Text(type.localizedLabel(.title)).tag(type) + Text(type.localizedLevelLabel(.title)).tag(type) } } .onChange(of: tournament.federalLevelCategory) { if tournament.federalLevelCategory == .unlisted { + tournament.hideTeamsWeight = true tournament.federalCategory = .unlisted tournament.federalAgeCategory = .unlisted + } else { + tournament.hideTeamsWeight = false + if tournament.federalCategory == .unlisted { + tournament.federalCategory = .men + } + if tournament.federalAgeCategory == .unlisted { + tournament.federalAgeCategory = .senior + } } } diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index 353fd7e..063f382 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -231,7 +231,7 @@ struct InscriptionManagerView: View { _setHash() }) { NavigationStack { - FileImportView() + FileImportView(defaultFileProvider: tournament.isAnimation() ? .custom : .frenchFederation) } .tint(.master) } @@ -308,44 +308,60 @@ struct InscriptionManagerView: View { .symbolVariant(filterMode == .all ? .none : .fill) } Menu { - if tournament.inscriptionClosed() == false { - Menu { - _sortingTypePickerView() - } label: { - Text("Méthode de sélection") - Text(tournament.teamSorting.localizedLabel()) + if tournament.isAnimation() == false { + if tournament.inscriptionClosed() == false { + Menu { + _sortingTypePickerView() + } label: { + Text("Méthode de sélection") + Text(tournament.teamSorting.localizedLabel()) + } + Divider() + rankingDateSourcePickerView(showDateInLabel: true) + + Divider() + Button { + tournament.lockRegistration() + _save() + } label: { + Label("Clôturer", systemImage: "lock") + } + Divider() + _sharingTeamsMenuView() + Button { + presentImportView = true + } label: { + Label("Importer beach-padel", systemImage: "square.and.arrow.down") + } + Link(destination: URLs.beachPadel.url) { + Label("beach-padel.app.fft.fr", systemImage: "safari") + } + } else { + + _sharingTeamsMenuView() + + Divider() + + Button { + tournament.unlockRegistration() + _save() + } label: { + Label("Ré-ouvrir", systemImage: "lock.open") + } } - Divider() + } else { rankingDateSourcePickerView(showDateInLabel: true) - - Divider() - Button { - tournament.lockRegistration() - _save() - } label: { - Label("Clôturer", systemImage: "lock") - } + Divider() - _sharingTeamsMenuView() - Button { - presentImportView = true - } label: { - Label("Importer beach-padel", systemImage: "square.and.arrow.down") - } - Link(destination: URLs.beachPadel.url) { - Label("beach-padel.app.fft.fr", systemImage: "safari") - } - } else { _sharingTeamsMenuView() Divider() - + Button { - tournament.unlockRegistration() - _save() + presentImportView = true } label: { - Label("Ré-ouvrir", systemImage: "lock.open") + Label("Importer un fichier", systemImage: "square.and.arrow.down") } } } label: { @@ -438,8 +454,11 @@ struct InscriptionManagerView: View { if presentSearch == false { _informationView() - _rankHandlerView() - _relatedTips() + + if tournament.isAnimation() == false { + _rankHandlerView() + _relatedTips() + } } let teams = searchField.isEmpty ? filteredTeams : filteredTeams.filter({ $0.contains(searchField.canonicalVersion) }) @@ -650,7 +669,7 @@ struct InscriptionManagerView: View { .listRowSeparator(.hidden) let registrationIssues = tournament.registrationIssues() - if registrationIssues > 0 { + if tournament.isAnimation() == false, registrationIssues > 0 { NavigationLink { InscriptionInfoView() .environment(tournament) @@ -660,7 +679,7 @@ struct InscriptionManagerView: View { .foregroundStyle(.logoRed) .fontWeight(.bold) } label: { - Text("Problèmes détéctés") + Text("Problèmes détectés") } } } diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index 6ca1e6c..4d3e783 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -19,8 +19,9 @@ struct TableStructureView: View { @State private var qualifiedPerGroupStage: Int = 0 @State private var groupStageAdditionalQualified: Int = 0 @State private var updatedElements: Set = Set() + @State private var structurePreset: PadelTournamentStructurePreset = .manual @FocusState private var stepperFieldIsFocused: Bool - + var qualifiedFromGroupStage: Int { groupStageCount * qualifiedPerGroupStage } @@ -51,6 +52,37 @@ struct TableStructureView: View { @ViewBuilder var body: some View { List { + + if tournament.state() != .build { + Section { + Picker(selection: $structurePreset) { + ForEach(PadelTournamentStructurePreset.allCases) { preset in + Text(preset.localizedStructurePresetTitle()).tag(preset) + } + } label: { + Text("Préréglage") + } + } footer: { + Text(structurePreset.localizedDescriptionStructurePresetTitle()) + } + .onChange(of: structurePreset) { + switch structurePreset { + case .manual: + teamCount = 24 + groupStageCount = 4 + teamsPerGroupStage = 4 + qualifiedPerGroupStage = 1 + groupStageAdditionalQualified = 0 + case .doubleGroupStage: + teamCount = 9 + groupStageCount = 3 + teamsPerGroupStage = 3 + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + } + } + } + Section { LabeledContent { StepperView(count: $teamCount, minimum: 4, maximum: 128) @@ -62,6 +94,8 @@ struct TableStructureView: View { } label: { 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 { @@ -73,33 +107,62 @@ struct TableStructureView: View { Text("Équipes par poule") } - LabeledContent { - StepperView(count: $qualifiedPerGroupStage, minimum: 1, maximum: (teamsPerGroupStage-1)) - } label: { - Text("Qualifiés par poule") - } - - if qualifiedPerGroupStage < teamsPerGroupStage - 1 { + if structurePreset == .manual { LabeledContent { - StepperView(count: $groupStageAdditionalQualified, minimum: 0, maximum: maxMoreQualified) + StepperView(count: $qualifiedPerGroupStage, minimum: 0, maximum: (teamsPerGroupStage-1)) } label: { - Text("Qualifiés supplémentaires") - Text(moreQualifiedLabel) + Text("Qualifié\(qualifiedPerGroupStage.pluralSuffix) par poule") } - .onChange(of: groupStageAdditionalQualified) { - if groupStageAdditionalQualified == groupStageCount { - qualifiedPerGroupStage += 1 - groupStageAdditionalQualified -= groupStageCount + + if qualifiedPerGroupStage < teamsPerGroupStage - 1 { + LabeledContent { + StepperView(count: $groupStageAdditionalQualified, minimum: 1, maximum: maxMoreQualified) + } label: { + Text("Qualifié\(groupStageAdditionalQualified.pluralSuffix) supplémentaires") + Text(moreQualifiedLabel) + } + .onChange(of: groupStageAdditionalQualified) { + if groupStageAdditionalQualified == groupStageCount { + qualifiedPerGroupStage += 1 + groupStageAdditionalQualified -= groupStageCount + } } } } if groupStageCount > 0 && teamsPerGroupStage > 0 { - LabeledContent { - let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 - Text(mp.formatted()) - } label: { - Text("Matchs à jouer par poule") + if structurePreset == .manual { + LabeledContent { + let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + } + } else { + LabeledContent { + let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + Text("Première phase") + } + + LabeledContent { + let mp = (groupStageCount * (groupStageCount - 1) / 2) + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + Text("Deuxième phase") + } + + LabeledContent { + let mp = groupStageCount - 1 + teamsPerGroupStage - 1 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par équipe") + Text("Total") + } + } } } @@ -111,27 +174,43 @@ struct TableStructureView: View { Section { let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0) if groupStageCount > 0 { + if structurePreset == .manual { + LabeledContent { + Text(teamsFromGroupStages.formatted()) + } label: { + Text("Équipes en poule") + } + + + LabeledContent { + Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) + } label: { + Text("Équipes qualifiées de poule") + } + } + } + + if structurePreset == .manual { + LabeledContent { - Text(teamsFromGroupStages.formatted()) + let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0) + Text(tsPure.formatted()) } label: { - Text("Équipes en poule") + Text("Nombre de têtes de série") } LabeledContent { - Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) + Text(tf.formatted()) } label: { - Text("Équipes qualifiées de poule") + Text("Équipes en tableau final") + } + } else { + LabeledContent { + let mp1 = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 * groupStageCount + let mp2 = (groupStageCount * (groupStageCount - 1) / 2) * teamsPerGroupStage + Text((mp1 + mp2).formatted()) + } label: { + Text("Total de matchs") } - } - LabeledContent { - let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0) - Text(tsPure.formatted()) - } label: { - Text("Nombre de têtes de série") - } - LabeledContent { - Text(tf.formatted()) - } label: { - Text("Équipes en tableau final") } } @@ -154,6 +233,13 @@ struct TableStructureView: View { _save(rebuildEverything: true) } } + + Section { + RowButtonView("Remise-à-zéro", role: .destructive) { + tournament.deleteGroupStages() + tournament.deleteStructure() + } + } } } .focused($stepperFieldIsFocused) @@ -283,7 +369,7 @@ struct TableStructureView: View { tournament.groupStageAdditionalQualified = groupStageAdditionalQualified if rebuildEverything { - tournament.deleteAndBuildEverything() + tournament.deleteAndBuildEverything(preset: structurePreset) } else if (rebuildEverything == false && requirements.contains(.groupStage)) { tournament.deleteGroupStages() tournament.buildGroupStages() diff --git a/PadelClub/Views/Tournament/Screen/TournamentCashierView.swift b/PadelClub/Views/Tournament/Screen/TournamentCashierView.swift index 2353007..b965fb5 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentCashierView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentCashierView.swift @@ -54,9 +54,15 @@ enum CashierDestination: Identifiable, Selectable, Equatable { case .summary: return nil case .groupStage(let groupStage): + if groupStage.tournamentObject()?.isFree() == true { + return groupStage.unsortedPlayers().filter({ $0.hasArrived == false }).count + } return groupStage.unsortedPlayers().filter({ $0.hasPaid() == false }).count case .bracket(let round): let playerRegistrations: [PlayerRegistration] = round.seeds().flatMap { $0.unsortedPlayers() } + if round.tournamentObject()?.isFree() == true { + return playerRegistrations.filter({ $0.hasArrived == false }).count + } return playerRegistrations.filter({ $0.hasPaid() == false }).count case .all(_): return nil @@ -156,7 +162,7 @@ struct TournamentCashierView: View { .environmentObject(cashierViewModel) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) - .navigationTitle("Encaissement") + .navigationTitle(tournament.isFree() ? "Présence" : "Encaissement") } } diff --git a/PadelClub/Views/Tournament/Screen/TournamentRankView.swift b/PadelClub/Views/Tournament/Screen/TournamentRankView.swift index baf9600..ba33832 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentRankView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentRankView.swift @@ -51,7 +51,7 @@ struct TournamentRankView: View { Logger.error(error) } } - + //affiche l'onglet sur le site, car sur le broadcast c'est dispo automatiquement de toute façon Toggle(isOn: $tournament.publishRankings) { Text("Publier sur Padel Club") if let url = tournament.shareURL(.rankings) { @@ -250,8 +250,9 @@ struct TournamentRankView: View { } } } + + Spacer() if tournament.isAnimation() == false && key > 0 { - Spacer() VStack(alignment: .trailing) { HStack(alignment: .lastTextBaseline, spacing: 0.0) { Text(tournament.tournamentLevel.points(for: key - 1, count: tournament.teamCount).formatted(.number.sign(strategy: .always()))) @@ -310,26 +311,8 @@ struct TournamentRankView: View { self.rankings.removeAll() let finalRanks = await tournament.finalRanking() - finalRanks.keys.sorted().forEach { rank in - if let rankedTeamIds = finalRanks[rank] { - let teams: [TeamRegistration] = rankedTeamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) } - self.rankings[rank] = teams - } - } - - await MainActor.run { - rankings.keys.sorted().forEach { rank in - if let rankedTeams = rankings[rank] { - rankedTeams.forEach { team in - team.finalRanking = rank - team.pointsEarned = tournament.isAnimation() ? nil : tournament.tournamentLevel.points(for: rank - 1, count: tournament.teamCount) - } - } - } - _save() - - calculating = false - } + self.rankings = await tournament.setRankings(finalRanks: finalRanks) + calculating = false } private func _save() { diff --git a/PadelClub/Views/Tournament/Shared/TournamentCellView.swift b/PadelClub/Views/Tournament/Shared/TournamentCellView.swift index 1e9ab62..be746d3 100644 --- a/PadelClub/Views/Tournament/Shared/TournamentCellView.swift +++ b/PadelClub/Views/Tournament/Shared/TournamentCellView.swift @@ -23,11 +23,17 @@ struct TournamentCellView: View { var body: some View { ForEach(tournament.tournaments, id: \.id) { build in - if navigation.agendaDestination == .around, let federalTournament = tournament as? FederalTournament { - NavigationLink { - TournamentSubscriptionView(federalTournament: federalTournament, build: build, user: dataStore.user) - } label: { - _buildView(build, existingTournament: event?.existingBuild(build)) + if let federalTournament = tournament as? FederalTournament { + if FederalDataViewModel.shared.isFederalTournamentValidForFilters(federalTournament, build: build) { + if navigation.agendaDestination == .around { + NavigationLink { + TournamentSubscriptionView(federalTournament: federalTournament, build: build, user: dataStore.user) + } label: { + _buildView(build, existingTournament: event?.existingBuild(build)) + } + } else { + _buildView(build, existingTournament: event?.existingBuild(build)) + } } } else { _buildView(build, existingTournament: event?.existingBuild(build)) @@ -71,7 +77,9 @@ struct TournamentCellView: View { VStack(alignment: .leading, spacing: 0.0) { if let tournament = tournament as? Tournament { HStack { - Text(tournament.locationLabel(displayStyle)).lineLimit(1) + Text(tournament.locationLabel(displayStyle)) + .lineLimit(1) + .truncationMode(.tail) .font(.caption) Spacer() if tournament.isPrivate { @@ -88,9 +96,11 @@ struct TournamentCellView: View { .font(.caption) } HStack(alignment: .bottom) { - Text(build.level.localizedLabel()) + Text(tournament.tournamentTitle(displayStyle, forBuild: build)) .fontWeight(.semibold) - if displayStyle == .wide { + .lineLimit(1) + .truncationMode(.tail) + if displayStyle == .wide, tournament.displayAgeAndCategory(forBuild: build) { VStack(alignment: .leading, spacing: 0) { Text(build.category.localizedLabel()) Text(build.age.localizedLabel()) @@ -122,8 +132,16 @@ struct TournamentCellView: View { .font(displayStyle == .wide ? .title : .title3) if displayStyle == .wide { - HStack { - Text(tournament.durationLabel()) + HStack(alignment: .top) { + VStack(alignment: .leading) { + let sub = tournament.subtitleLabel(forBuild: build) + if sub.isEmpty == false { + Text(sub) + .lineLimit(1) + .truncationMode(.tail) + } + Text(tournament.durationLabel()) + } Spacer() if let tournament = tournament as? Tournament, tournament.isCanceled == false, let teamCount { let hasStarted = tournament.inscriptionClosed() || tournament.hasStarted() @@ -131,7 +149,6 @@ struct TournamentCellView: View { Text(word + teamCount.pluralSuffix) } } - Text(tournament.subtitleLabel()).lineLimit(1) } else { Text(build.category.localizedLabel()) Text(build.age.localizedLabel()) diff --git a/PadelClub/Views/Tournament/TournamentBuildView.swift b/PadelClub/Views/Tournament/TournamentBuildView.swift index a11e055..50dbfca 100644 --- a/PadelClub/Views/Tournament/TournamentBuildView.swift +++ b/PadelClub/Views/Tournament/TournamentBuildView.swift @@ -30,7 +30,11 @@ struct TournamentBuildView: View { ProgressView() } } label: { - Text("Poules") + if tournament.groupStages(atStep: 1).isEmpty == false { + Text("1ère phase de poules") + } else { + Text("Poules") + } if tournament.groupStagesAreOver(), tournament.moreQualifiedToDraw() > 0 { let moreQualifiedToDraw = tournament.moreQualifiedToDraw() Text("Qualifié\(moreQualifiedToDraw.pluralSuffix) sortant\(moreQualifiedToDraw.pluralSuffix) manquant\(moreQualifiedToDraw.pluralSuffix)").foregroundStyle(.logoRed) @@ -55,6 +59,23 @@ struct TournamentBuildView: View { } } + if tournament.groupStages(atStep: 1).isEmpty == false { + NavigationLink { + GroupStagesView(tournament: tournament, step: 1) + } label: { + LabeledContent { + if tournament.groupStagesAreOver(atStep: 1) { + Text("terminées") + } else { + Text("") + } + } label: { + Text("2ème phase de poules") + } + + } + } + if tournament.rounds().isEmpty == false { NavigationLink(value: Screen.round) { LabeledContent { @@ -92,24 +113,6 @@ struct TournamentBuildView: View { Section { - #if DEBUG - NavigationLink(value: Screen.rankings) { - LabeledContent { - if tournament.publishRankings == false { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.logoYellow) - } else { - Image(systemName: "checkmark") - .foregroundStyle(.green) - } - } label: { - Text("Classement final des équipes") - if tournament.publishRankings == false { - Text("Vérifiez le classement avant de publier").foregroundStyle(.logoRed) - } - } - } - #else if tournament.hasEnded() { NavigationLink(value: Screen.rankings) { LabeledContent { @@ -128,7 +131,6 @@ struct TournamentBuildView: View { } } } - #endif if state == .running || state == .finished { TournamentInscriptionView(tournament: tournament) TournamentBroadcastRowView(tournament: tournament) @@ -185,7 +187,7 @@ struct TournamentBuildView: View { ProgressView() } } label: { - Text("Encaissement") + Text(tournament.isFree() ? "Présence" : "Encaissement") if let tournamentStatus { Text(tournamentStatus.label).lineLimit(1) } else { diff --git a/PadelClub/Views/Tournament/TournamentView.swift b/PadelClub/Views/Tournament/TournamentView.swift index 7dbe23b..e12017a 100644 --- a/PadelClub/Views/Tournament/TournamentView.swift +++ b/PadelClub/Views/Tournament/TournamentView.swift @@ -141,7 +141,7 @@ struct TournamentView: View { ToolbarItem(placement: .principal) { VStack(spacing: -4.0) { Text(tournament.tournamentTitle(.title)).font(.headline) - Text(tournament.formattedDate()) + Text(tournament.formattedDate(.title)) .font(.subheadline).foregroundStyle(.secondary) } .popoverTip(tournamentSelectionTip) diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index 7d19cb5..3947937 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -150,7 +150,7 @@ final class ServerDataTests: XCTestCase { return } - let groupStage = GroupStage(tournament: tournamentId, index: 2, size: 3, matchFormat: MatchFormat.nineGames, startDate: Date(), name: "Yeah!") + let groupStage = GroupStage(tournament: tournamentId, index: 2, size: 3, matchFormat: MatchFormat.nineGames, startDate: Date(), name: "Yeah!", step: 1) let gs: GroupStage = try await StoreCenter.main.service().post(groupStage) assert(gs.tournament == groupStage.tournament) @@ -159,6 +159,8 @@ final class ServerDataTests: XCTestCase { assert(gs.size == groupStage.size) assert(gs.matchFormat == groupStage.matchFormat) assert(gs.startDate != nil) + assert(gs.step == groupStage.step) + }