diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index ee14553..6c7e3f8 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ FF1F4B8A2BFA02A4000B4573 /* groupstage-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B772BFA0105000B4573 /* groupstage-template.html */; }; FF1F4B8B2BFA02A4000B4573 /* groupstageentrant-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B792BFA0105000B4573 /* groupstageentrant-template.html */; }; FF1F4B8C2BFA02A4000B4573 /* match-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B7D2BFA0105000B4573 /* match-template.html */; }; + FF2B51552C7A4DAF00FFF126 /* PlanningByCourtView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2B51542C7A4DAF00FFF126 /* PlanningByCourtView.swift */; }; FF2B6F5E2C036A1500835EE7 /* EventLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2B6F5D2C036A1400835EE7 /* EventLinksView.swift */; }; FF2EFBF02BDE295E0049CE3B /* SendToAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EFBEF2BDE295E0049CE3B /* SendToAllView.swift */; }; FF3795622B9396D0004EA093 /* PadelClubApp.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FF3795602B9396D0004EA093 /* PadelClubApp.xcdatamodeld */; }; @@ -451,6 +452,7 @@ FF1F4B7E2BFA0105000B4573 /* player-template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "player-template.html"; sourceTree = ""; }; FF1F4B7F2BFA0105000B4573 /* tournament-template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "tournament-template.html"; sourceTree = ""; }; FF1F4B812BFA0124000B4573 /* PrintSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintSettingsView.swift; sourceTree = ""; }; + FF2B51542C7A4DAF00FFF126 /* PlanningByCourtView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanningByCourtView.swift; sourceTree = ""; }; FF2B6F5D2C036A1400835EE7 /* EventLinksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLinksView.swift; sourceTree = ""; }; FF2EFBEF2BDE295E0049CE3B /* SendToAllView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToAllView.swift; sourceTree = ""; }; FF3795612B9396D0004EA093 /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; @@ -1367,6 +1369,7 @@ isa = PBXGroup; children = ( FFF9644F2BC25E3700EEF017 /* PlanningView.swift */, + FF2B51542C7A4DAF00FFF126 /* PlanningByCourtView.swift */, FFF964522BC262B000EEF017 /* PlanningSettingsView.swift */, FFF964542BC266CF00EEF017 /* SchedulerView.swift */, FFF964562BC26B3400EEF017 /* RoundScheduleEditorView.swift */, @@ -1675,6 +1678,7 @@ FFC2DCB22BBE75D40046DB9F /* LoserRoundView.swift in Sources */, FF90FC1D2C44FB3E009339B2 /* AddTeamView.swift in Sources */, FF9267FC2BCE84870080F940 /* PlayerPayView.swift in Sources */, + FF2B51552C7A4DAF00FFF126 /* PlanningByCourtView.swift in Sources */, FFA6D7852BB0B795003A31F3 /* FileImportManager.swift in Sources */, FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */, FFF8ACCD2B92367B008466FA /* FederalPlayer.swift in Sources */, @@ -1976,7 +1980,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; OTHER_SWIFT_FLAGS = "-Onone"; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; @@ -2024,7 +2028,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-function-bodies=5 -Xfrontend -warn-long-expression-type-checking=20 -Xfrontend -warn-long-function-bodies=50"; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; diff --git a/PadelClub/Data/MatchScheduler.swift b/PadelClub/Data/MatchScheduler.swift index 38badde..3e7c92c 100644 --- a/PadelClub/Data/MatchScheduler.swift +++ b/PadelClub/Data/MatchScheduler.swift @@ -30,6 +30,7 @@ final class MatchScheduler : ModelObject, Storable { var shouldEndRoundBeforeStartingNext: Bool var groupStageChunkCount: Int? var overrideCourtsUnavailability: Bool = false + var shouldTryToFillUpCourtsAvailable: Bool = false init(tournament: String, timeDifferenceLimit: Int = 5, @@ -41,7 +42,7 @@ final class MatchScheduler : ModelObject, Storable { rotationDifferenceIsImportant: Bool = false, shouldHandleUpperRoundSlice: Bool = true, shouldEndRoundBeforeStartingNext: Bool = true, - groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false) { + groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) { self.tournament = tournament self.timeDifferenceLimit = timeDifferenceLimit self.loserBracketRotationDifference = loserBracketRotationDifference @@ -54,6 +55,7 @@ final class MatchScheduler : ModelObject, Storable { self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext self.groupStageChunkCount = groupStageChunkCount self.overrideCourtsUnavailability = overrideCourtsUnavailability + self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable } enum CodingKeys: String, CodingKey { @@ -70,6 +72,7 @@ final class MatchScheduler : ModelObject, Storable { case _shouldEndRoundBeforeStartingNext = "shouldEndRoundBeforeStartingNext" case _groupStageChunkCount = "groupStageChunkCount" case _overrideCourtsUnavailability = "overrideCourtsUnavailability" + case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable" } var courtsUnavailability: [DateInterval]? { @@ -521,9 +524,12 @@ final class MatchScheduler : ModelObject, Storable { //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)") - 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") - return false + + 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") + return false + } } diff --git a/PadelClub/ViewModel/SeedInterval.swift b/PadelClub/ViewModel/SeedInterval.swift index 35a3ddd..e34f620 100644 --- a/PadelClub/ViewModel/SeedInterval.swift +++ b/PadelClub/ViewModel/SeedInterval.swift @@ -51,10 +51,10 @@ struct SeedInterval: Hashable, Comparable { } func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { - if dimension < 2 { - return "#\(first) / #\(last)" + if dimension < 3 { + return "\(first)\(first.ordinalFormattedSuffix()) place" } else { - return "#\(first) à #\(last)" + return "Place \(first) à \(last)" } } } diff --git a/PadelClub/Views/Match/Components/MatchDateView.swift b/PadelClub/Views/Match/Components/MatchDateView.swift index 37c1a3f..d969252 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 retard") + Text("en attente") .foregroundStyle(Color.master) .underline() } diff --git a/PadelClub/Views/Match/MatchSummaryView.swift b/PadelClub/Views/Match/MatchSummaryView.swift index 2e834f2..16fd9bf 100644 --- a/PadelClub/Views/Match/MatchSummaryView.swift +++ b/PadelClub/Views/Match/MatchSummaryView.swift @@ -29,7 +29,7 @@ struct MatchSummaryView: View { if let groupStage = match.groupStageObject { self.roundTitle = groupStage.groupStageTitle() } else if let round = match.roundObject { - self.roundTitle = round.roundTitle(.short) + self.roundTitle = round.roundTitle(matchViewStyle == .feedStyle ? .wide : .short) } else { self.roundTitle = nil } @@ -46,9 +46,6 @@ struct MatchSummaryView: View { var body: some View { VStack(alignment: .leading) { if matchViewStyle != .plainStyle { - if matchViewStyle == .feedStyle, let tournament = match.currentTournament() { - Text(tournament.tournamentTitle()) - } HStack { if matchViewStyle != .sectionedStandardStyle { if let roundTitle { @@ -59,7 +56,7 @@ struct MatchSummaryView: View { } } Spacer() - if let courtName { + if let courtName, matchViewStyle != .feedStyle { Spacer() Text(courtName) .foregroundStyle(.gray) diff --git a/PadelClub/Views/Planning/PlanningByCourtView.swift b/PadelClub/Views/Planning/PlanningByCourtView.swift new file mode 100644 index 0000000..ba1bdef --- /dev/null +++ b/PadelClub/Views/Planning/PlanningByCourtView.swift @@ -0,0 +1,144 @@ +// +// PlanningByCourtView.swift +// PadelClub +// +// Created by razmig on 24/08/2024. +// + +import SwiftUI + +struct PlanningByCourtView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament: Tournament + + let matches: [Match] + @Binding var selectedScheduleDestination: ScheduleDestination? + @State private var timeSlots: [Date:[Match]] + @State private var days: [Date] + @State private var keys: [Date] + @State private var courts: [Int] + @State private var viewByCourt: Bool = false + @State private var courtSlots: [Int:[Match]] + @State private var selectedDay: Date + @State private var selectedCourt: Int = 0 + + + init(matches: [Match], selectedScheduleDestination: Binding, startDate: Date) { + self.matches = matches + _selectedScheduleDestination = selectedScheduleDestination + let timeSlots = Dictionary(grouping: matches) { $0.startDate ?? .distantFuture } + let courtSlots = Dictionary(grouping: matches) { $0.courtIndex ?? Int.max} + _timeSlots = State(wrappedValue: timeSlots) + _courtSlots = State(wrappedValue: courtSlots) + _days = State(wrappedValue: Set(timeSlots.keys.map { $0.startOfDay }).sorted()) + _keys = State(wrappedValue: timeSlots.keys.sorted()) + _courts = State(wrappedValue: courtSlots.keys.sorted()) + + _selectedDay = State(wrappedValue: startDate) + } + + var body: some View { + List { + _byCourtView() + } + .overlay { + if matches.allSatisfy({ $0.startDate == nil }) { + ContentUnavailableView { + Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") + } description: { + Text("Vous n'avez pas encore défini d'horaire pour les différentes phases du tournoi") + } actions: { + RowButtonView("Horaire intelligent") { + selectedScheduleDestination = nil + } + .padding(.horizontal) + } + } + } + .navigationTitle(Text(selectedDay.formatted(.dateTime.day().weekday().month()))) + .toolbar { + if days.count > 1 { + ToolbarTitleMenu { + Picker(selection: $selectedDay) { + ForEach(days, id: \.self) { day in + Text(day.formatted(.dateTime.day().weekday().month())).tag(day as Date?) + } + } label: { + Text("Jour") + } + .pickerStyle(.automatic) + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + if courts.count > 1 { + Picker(selection: $selectedCourt) { + ForEach(courts, id: \.self) { courtIndex in + if courtIndex == Int.max { + Image(systemName: "rectangle.slash").tag(Int.max) + } else { + Text(tournament.courtName(atIndex: courtIndex)).tag(courtIndex) + } + } + } label: { + Text("Terrain") + } + .pickerStyle(.automatic) + } + } + } + } + + @ViewBuilder + func _byCourtView() -> some View { + if let _matches = courtSlots[selectedCourt]?.filter({ $0.startDate?.dayInt == selectedDay.dayInt }) { + let _sortedMatches = _matches.sorted(by: \.computedStartDateForSorting) + ForEach(_sortedMatches.indices, id: \.self) { index in + let match = _sortedMatches[index] + Section { + MatchRowView(match: match, matchViewStyle: .feedStyle) + } header: { + if let startDate = match.startDate { + if index > 0 { + if match.confirmed { + Text("Pas avant \(startDate.formatted(date: .omitted, time: .shortened))") + } else { + Text("Suivi de") + } + } else { + Text("Démarrage à \(startDate.formatted(date: .omitted, time: .shortened))") + } + } else { + Text("Aucun horaire") + } + } + .headerProminence(.increased) + } + } else if courtSlots.isEmpty == false { + ContentUnavailableView { + Label("Aucun match plannifié", systemImage: "clock.badge.questionmark") + } description: { + Text("Aucun match n'a été plannifié sur ce terrain et au jour sélectionné") + } actions: { + } + } + } + + private func _matchesCount(inDayInt dayInt: Int) -> Int { + timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count + } + + private func _timeSlotView(key: Date, matches: [Match]) -> some View { + LabeledContent { + 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: ", ")) + } + } + + fileprivate func _formattedMatchCount(_ count: Int) -> String { + return "\(count.formatted()) match\(count.pluralSuffix)" + } + + +} diff --git a/PadelClub/Views/Planning/PlanningSettingsView.swift b/PadelClub/Views/Planning/PlanningSettingsView.swift index 56b8ac1..3528cc1 100644 --- a/PadelClub/Views/Planning/PlanningSettingsView.swift +++ b/PadelClub/Views/Planning/PlanningSettingsView.swift @@ -246,20 +246,38 @@ struct PlanningSettingsView: View { Text("Vous pouvez indiquer le nombre de poule démarrant en même temps.") } } - + Section { - Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) { - Text("Ne pas tenir compte des autres tournois") - } - Toggle(isOn: $matchScheduler.randomizeCourts) { Text("Distribuer les terrains au hasard") } - + } + + Section { + Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) { + Text("Remplir au maximum les terrains d'une rotation") + } + } footer: { + Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.") + } + + Section { Toggle(isOn: $matchScheduler.shouldHandleUpperRoundSlice) { - Text("Équilibrer les matchs d'une manche sur plusieurs tours") + Text("Équilibrer les matchs d'une manche") } - + } footer: { + Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.") + } + + Section { + Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) { + Text("Ne pas tenir compte des autres tournois") + } + } footer: { + Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi est toujours considéré comme libre.") + } + + Section { Toggle(isOn: $matchScheduler.shouldEndRoundBeforeStartingNext) { Text("Finir une manche, classement inclus avant de continuer") } @@ -267,19 +285,23 @@ struct PlanningSettingsView: View { Section { Toggle(isOn: $matchScheduler.accountUpperBracketBreakTime) { - Text("Tenir compte des pauses") - Text("Tableau") + Text("Tenir compte des temps de pause réglementaires") } + } header: { + Text("Tableau") + } + Section { Toggle(isOn: $matchScheduler.accountLoserBracketBreakTime) { - Text("Tenir compte des pauses") - Text("Classement") + Text("Tenir compte des temps de pause réglementaires") } + } header: { + Text("Classement") } Section { Toggle(isOn: $matchScheduler.rotationDifferenceIsImportant) { - Text("Forcer un créneau supplémentaire entre 2 phases") + Text("Forcer une rotation d'attente supplémentaire entre 2 phases") } LabeledContent { @@ -295,6 +317,8 @@ struct PlanningSettingsView: View { Text("Classement") } .disabled(matchScheduler.rotationDifferenceIsImportant == false) + } footer: { + Text("Cette option ajoute du temps entre 2 rotations, permettant ainsi de mieux configurer plusieurs tournois se déroulant en même temps.") } Section { @@ -304,6 +328,9 @@ struct PlanningSettingsView: View { Text("Optimisation des créneaux") Text("Si libre plus de \(matchScheduler.timeDifferenceLimit) minutes") } + } footer: { + Text("Cette option essaie d'optimiser les créneaux disponibles à partir du moment où ils sont à priori libre plus de \(matchScheduler.timeDifferenceLimit) minutes.") + } } diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index ae4a4e5..1e0c0fc 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -28,46 +28,7 @@ struct PlanningView: View { var body: some View { List { - if matches.allSatisfy({ $0.startDate == nil }) == false { - ForEach(days, id: \.self) { day in - Section { - ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in - if let _matches = timeSlots[key] { - DisclosureGroup { - ForEach(_matches) { match in - NavigationLink { - MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) - } label: { - LabeledContent { - if let courtName = match.courtName() { - Text(courtName) - } - } label: { - if let groupStage = match.groupStageObject { - Text(groupStage.groupStageTitle()) - } else if let round = match.roundObject { - Text(round.roundTitle()) - } - Text(match.matchTitle()) - } - } - } - } label: { - _timeSlotView(key: key, matches: _matches) - } - } - } - } header: { - HStack { - Text(day.formatted(.dateTime.day().weekday().month())) - Spacer() - let count = _matchesCount(inDayInt: day.dayInt) - Text(self._formattedMatchCount(count)) - } - } - .headerProminence(.increased) - } - } + _bySlotView() } .overlay { if matches.allSatisfy({ $0.startDate == nil }) { @@ -85,6 +46,50 @@ struct PlanningView: View { } } + @ViewBuilder + func _bySlotView() -> some View { + if matches.allSatisfy({ $0.startDate == nil }) == false { + ForEach(days, id: \.self) { day in + Section { + ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in + if let _matches = timeSlots[key] { + DisclosureGroup { + ForEach(_matches) { match in + NavigationLink { + MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) + } label: { + LabeledContent { + if let courtName = match.courtName() { + Text(courtName) + } + } label: { + if let groupStage = match.groupStageObject { + Text(groupStage.groupStageTitle()) + } else if let round = match.roundObject { + Text(round.roundTitle()) + } + Text(match.matchTitle()) + } + } + } + } label: { + _timeSlotView(key: key, matches: _matches) + } + } + } + } header: { + HStack { + Text(day.formatted(.dateTime.day().weekday().month())) + Spacer() + let count = _matchesCount(inDayInt: day.dayInt) + Text(self._formattedMatchCount(count)) + } + } + .headerProminence(.increased) + } + } + } + private func _matchesCount(inDayInt dayInt: Int) -> Int { timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count } diff --git a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift index bb7bb55..1fdc503 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift @@ -28,6 +28,7 @@ enum ScheduleDestination: String, Identifiable, Selectable, Equatable { var id: String { self.rawValue } case planning + case planningByCourt case scheduleGroupStage case scheduleBracket @@ -38,6 +39,8 @@ enum ScheduleDestination: String, Identifiable, Selectable, Equatable { case .scheduleBracket: return "Tableau" case .planning: + return "Horaires" + case .planningByCourt: return "Prog." } } @@ -63,7 +66,7 @@ struct TournamentScheduleView: View { init(tournament: Tournament) { self.tournament = tournament - var destinations = [ScheduleDestination.planning] + var destinations = [ScheduleDestination.planning, ScheduleDestination.planningByCourt] if tournament.groupStages().isEmpty == false { destinations.append(.scheduleGroupStage) } @@ -76,6 +79,7 @@ struct TournamentScheduleView: View { var body: some View { VStack(spacing: 0) { GenericDestinationPickerView(selectedDestination: $selectedScheduleDestination, destinations: allDestinations, nilDestinationIsValid: true) + let allMatches = tournament.allMatches() switch selectedScheduleDestination { case .none: PlanningSettingsView(tournament: tournament) @@ -86,11 +90,14 @@ struct TournamentScheduleView: View { case .scheduleBracket: SchedulerView(tournament: tournament, destination: selectedSchedule) case .planning: - PlanningView(matches: tournament.allMatches(), selectedScheduleDestination: $selectedScheduleDestination) + PlanningView(matches: allMatches, selectedScheduleDestination: $selectedScheduleDestination) + case .planningByCourt: + PlanningByCourtView(matches: allMatches, selectedScheduleDestination: $selectedScheduleDestination, startDate: allMatches.filter({ $0.isRunning() }).sorted(by: \.computedStartDateForSorting).first?.startDate ?? tournament.startDate) } } } .navigationBarTitleDisplayMode(.inline) + .toolbarRole(.editor) .toolbarBackground(.visible, for: .navigationBar) .navigationTitle("Horaires et formats") }