sync2
Laurent 1 year ago
commit b87c9003c8
  1. 24
      PadelClub.xcodeproj/project.pbxproj
  2. 7
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  3. 10
      PadelClub/Data/Federal/FederalTournament.swift
  4. 4
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  5. 1
      PadelClub/Data/Federal/PlayerHolder.swift
  6. 77
      PadelClub/Data/GroupStage.swift
  7. 13
      PadelClub/Data/Match.swift
  8. 307
      PadelClub/Data/MatchScheduler.swift
  9. 36
      PadelClub/Data/PlayerRegistration.swift
  10. 3
      PadelClub/Data/Round.swift
  11. 12
      PadelClub/Data/TeamRegistration.swift
  12. 452
      PadelClub/Data/Tournament.swift
  13. 4
      PadelClub/Extensions/Calendar+Extensions.swift
  14. 8
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  15. 5
      PadelClub/Extensions/String+Extensions.swift
  16. 2
      PadelClub/PadelClubApp.swift
  17. 38
      PadelClub/Utils/DisplayContext.swift
  18. 60
      PadelClub/Utils/FileImportManager.swift
  19. 2
      PadelClub/Utils/HtmlGenerator.swift
  20. 15
      PadelClub/Utils/LocationManager.swift
  21. 102
      PadelClub/Utils/PadelRule.swift
  22. 25
      PadelClub/Utils/Tips.swift
  23. 10
      PadelClub/ViewModel/AgendaDestination.swift
  24. 29
      PadelClub/ViewModel/FederalDataViewModel.swift
  25. 4
      PadelClub/ViewModel/SearchViewModel.swift
  26. 33
      PadelClub/ViewModel/Selectable.swift
  27. 10
      PadelClub/Views/Calling/CallMessageCustomizationView.swift
  28. 2
      PadelClub/Views/Calling/Components/MenuWarningView.swift
  29. 17
      PadelClub/Views/Cashier/CashierDetailView.swift
  30. 151
      PadelClub/Views/Cashier/CashierSettingsView.swift
  31. 103
      PadelClub/Views/Cashier/CashierView.swift
  32. 16
      PadelClub/Views/Cashier/Event/EventSettingsView.swift
  33. 5
      PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift
  34. 10
      PadelClub/Views/Club/ClubDetailView.swift
  35. 2
      PadelClub/Views/Club/ClubSearchView.swift
  36. 12
      PadelClub/Views/Club/CourtView.swift
  37. 16
      PadelClub/Views/Components/FooterButtonView.swift
  38. 4
      PadelClub/Views/Components/GenericDestinationPickerView.swift
  39. 13
      PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift
  40. 14
      PadelClub/Views/GroupStage/Components/GroupStageTeamView.swift
  41. 37
      PadelClub/Views/GroupStage/GroupStageView.swift
  42. 28
      PadelClub/Views/GroupStage/GroupStagesSettingsView.swift
  43. 13
      PadelClub/Views/GroupStage/GroupStagesView.swift
  44. 38
      PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift
  45. 4
      PadelClub/Views/GroupStage/Shared/GroupStageTeamReplacementView.swift
  46. 2
      PadelClub/Views/Match/Components/MatchDateView.swift
  47. 15
      PadelClub/Views/Match/Components/MatchTeamDetailView.swift
  48. 16
      PadelClub/Views/Match/Components/PlayerBlockView.swift
  49. 44
      PadelClub/Views/Match/MatchDetailView.swift
  50. 4
      PadelClub/Views/Match/MatchSummaryView.swift
  51. 84
      PadelClub/Views/Navigation/Agenda/ActivityView.swift
  52. 8
      PadelClub/Views/Navigation/Agenda/CalendarView.swift
  53. 4
      PadelClub/Views/Navigation/Agenda/EventListView.swift
  54. 69
      PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift
  55. 10
      PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift
  56. 4
      PadelClub/Views/Navigation/Toolbox/RankCalculatorView.swift
  57. 29
      PadelClub/Views/Navigation/Toolbox/ToolboxView.swift
  58. 2
      PadelClub/Views/Planning/GroupStageScheduleEditorView.swift
  59. 1
      PadelClub/Views/Planning/PlanningByCourtView.swift
  60. 8
      PadelClub/Views/Planning/PlanningSettingsView.swift
  61. 48
      PadelClub/Views/Planning/PlanningView.swift
  62. 4
      PadelClub/Views/Planning/SchedulerView.swift
  63. 18
      PadelClub/Views/Player/Components/EditablePlayerView.swift
  64. 2
      PadelClub/Views/Player/Components/PlayerPopoverView.swift
  65. 15
      PadelClub/Views/Player/PlayerDetailView.swift
  66. 81
      PadelClub/Views/Round/LoserRoundSettingsView.swift
  67. 30
      PadelClub/Views/Round/RoundView.swift
  68. 112
      PadelClub/Views/Shared/ImportedPlayerView.swift
  69. 44
      PadelClub/Views/Shared/SelectablePlayerListView.swift
  70. 2
      PadelClub/Views/Shared/TournamentFilterView.swift
  71. 2
      PadelClub/Views/Team/Components/TeamHeaderView.swift
  72. 17
      PadelClub/Views/Team/Components/TeamWeightView.swift
  73. 20
      PadelClub/Views/Team/EditingTeamView.swift
  74. 42
      PadelClub/Views/Team/TeamRowView.swift
  75. 18
      PadelClub/Views/Tournament/FileImportView.swift
  76. 298
      PadelClub/Views/Tournament/Screen/AddTeamView.swift
  77. 70
      PadelClub/Views/Tournament/Screen/BroadcastView.swift
  78. 19
      PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift
  79. 163
      PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift
  80. 11
      PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift
  81. 83
      PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift
  82. 156
      PadelClub/Views/Tournament/Screen/TableStructureView.swift
  83. 8
      PadelClub/Views/Tournament/Screen/TournamentCashierView.swift
  84. 27
      PadelClub/Views/Tournament/Screen/TournamentRankView.swift
  85. 39
      PadelClub/Views/Tournament/Shared/TournamentCellView.swift
  86. 44
      PadelClub/Views/Tournament/TournamentBuildView.swift
  87. 2
      PadelClub/Views/Tournament/TournamentView.swift
  88. 4
      PadelClubTests/ServerDataTests.swift

@ -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 = "";

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

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

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

@ -27,6 +27,7 @@ protocol PlayerHolder {
func isNotFromCurrentDate() -> Bool
func getBirthYear() -> Int?
func getProgression() -> Int
func getComputedRank() -> Int?
}
extension PlayerHolder {

@ -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..<size).combinations(ofCount: 2))[safe: matchIndex] ?? []
}
@ -274,7 +308,7 @@ final class GroupStage: ModelObject, Storable {
func localizedMatchUpLabel(for matchIndex: Int) -> 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,20 +360,23 @@ 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 {
@ -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"
}
}

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

@ -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 {
@ -123,7 +123,9 @@ final class MatchScheduler : ModelObject, Storable {
}
times.forEach({ time in
lastDate = time
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..<maxMatchesCount).flatMap { index in
_groupStages.compactMap { group in
// Use optional subscript to safely access matches
// Safely access matches, return nil if index is out of bounds
let playedMatches = group.playedMatches()
return playedMatches.indices.contains(index) ? playedMatches[index] : nil
}
}
var slots = [GroupStageTimeMatch]()
var availableMatchs = flattenedMatches
var availableMatches = flattenedMatches
var rotationIndex = 0
var teamsPerRotation = [Int: [String]]()
var freeCourtPerRotation = [Int: [Int]]()
var groupLastRotation = [Int: Int]()
var teamsPerRotation = [Int: [String]]() // Tracks teams assigned to each rotation
var freeCourtPerRotation = [Int: [Int]]() // Tracks free courts per rotation
var groupLastRotation = [Int: Int]() // Tracks the last rotation each group was involved in
let courtsUnavailability = courtsUnavailability
while slots.count < flattenedMatches.count {
print("Starting rotation \(rotationIndex) with \(availableMatches.count) matches left")
teamsPerRotation[rotationIndex] = []
freeCourtPerRotation[rotationIndex] = []
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }.map { ($0.groupIndex, 1) }
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }
.map { ($0.groupIndex, 1) }
let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +)
var rotationMatches = Array(availableMatchs.filter({ match in
teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true
var rotationMatches = Array(availableMatches.filter({ match in
// Check if all teams from the match are not already scheduled in the current rotation
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamId($0) })
if !teamsAvailable {
print("Match \(match.roundAndMatchTitle()) has teams already scheduled in rotation \(rotationIndex)")
}
return teamsAvailable
}).prefix(numberOfCourtsAvailablePerRotation))
if rotationIndex > 0 {
@ -216,28 +227,42 @@ final class MatchScheduler : ModelObject, Storable {
}
(0..<numberOfCourtsAvailablePerRotation).forEach { courtIndex in
//print(mt.map { ($0.bracket!.index.intValue, counts[$0.bracket!.index.intValue]) })
print("Checking availability for court \(courtIndex) in rotation \(rotationIndex)")
if let first = rotationMatches.first(where: { match in
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let timeIntervalToAdd = (Double(rotationIndex)) * Double(estimatedDuration) * 60
let timeIntervalToAdd = Double(rotationIndex) * Double(estimatedDuration) * 60
let rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability)
if courtIndex >= 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..<rotationIndex {
let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted()
@ -257,10 +285,15 @@ final class MatchScheduler : ModelObject, Storable {
}
}
return GroupStageMatchDispatcher(timedMatches: organizedSlots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, groupLastRotation: groupLastRotation)
return GroupStageMatchDispatcher(
timedMatches: organizedSlots,
freeCourtPerRotation: freeCourtPerRotation,
rotationCount: rotationIndex,
groupLastRotation: groupLastRotation
)
}
func rotationDifference(loserBracket: Bool) -> Int {
if loserBracket {
return loserBracketRotationDifference
@ -270,71 +303,97 @@ 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
}
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 {
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,7 +428,6 @@ 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
@ -377,6 +435,9 @@ final class MatchScheduler : ModelObject, Storable {
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 {
_startDate = match.startDate
@ -389,19 +450,16 @@ final class MatchScheduler : ModelObject, Storable {
slots.append(timeMatch)
}
if slots.isEmpty == false {
if !slots.isEmpty {
rotationIndex += 1
}
var freeCourtPerRotation = [Int: [Int]]()
let availableCourt = numberOfCourtsAvailablePerRotation
var courts = initialCourts ?? (0..<availableCourt).map { $0 }
var shouldStartAtDispatcherDate = rotationIndex > 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
@ -413,23 +471,28 @@ final class MatchScheduler : ModelObject, Storable {
courts = rotationIndex == 0 ? courts : (0..<availableCourt).map { $0 }
}
courts.sort()
print("courts available at rotation \(rotationIndex)", courts)
print("rotationStartDate", rotationStartDate)
if rotationIndex > 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,34 +500,35 @@ 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..<rotationIndex {
let courtsSorted = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted()
let courtsSorted = slots.filter { $0.rotationIndex == i }.map { $0.courtIndex }.sorted()
let courts = randomizeCourts ? courtsSorted.shuffled() : courtsSorted
var matches = slots.filter({ $0.rotationIndex == i }).sorted(using: .keyPath(\.courtIndex))
var matches = slots.filter { $0.rotationIndex == i }.sorted(using: .keyPath(\.courtIndex))
for j in 0..<matches.count {
matches[j].courtIndex = courts[j]
@ -472,110 +536,88 @@ final class MatchScheduler : ModelObject, Storable {
}
}
print("Finished roundDispatcher with \(organizedSlots.count) scheduled matches")
return MatchDispatcher(timedMatches: slots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound)
}
func dispatchCourts(availableCourts: Int, courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) {
var matchPerRound = [String: Int]()
var minimumTargetedEndDate: Date = rotationStartDate
print("dispatchCourts", courts.sorted(), rotationStartDate, rotationIndex)
var minimumTargetedEndDate = rotationStartDate
// Log dispatch attempt
print("Dispatching courts for rotation \(rotationIndex) with start date \(rotationStartDate) and available courts \(courts.sorted())")
for (courtPosition, courtIndex) in courts.sorted().enumerated() {
if let first = availableMatchs.first(where: { match in
print("trying to find a match for \(courtIndex) in \(rotationIndex)")
if let firstMatch = availableMatchs.first(where: { match in
print("Trying to find a match for court \(courtIndex) in rotation \(rotationIndex)")
let roundObject = match.roundObject!
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability)
print("courtsUnavailable \(courtsUnavailable)")
if courtPosition >= 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 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 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")
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")
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
}
}
//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 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")
} 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)")
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 })
} else {
freeCourtPerRotation[rotationIndex]!.append(courtIndex)
}
}
matchPerRound[firstMatch.roundObject!.id, default: 0] += 1
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..<availableCourts).map { $0 }
let timeMatch = TimeMatch(
matchID: firstMatch.id,
rotationIndex: rotationIndex,
courtIndex: courtIndex,
startDate: rotationStartDate,
durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration),
minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime
)
slots.append(timeMatch)
availableMatchs.removeAll(where: { $0.id == firstMatch.id })
} else {
freeCourts = courtsUsed.filter { (courtIndex, availableDate) in
availableDate <= minimumTargetedEndDate
}.sorted(by: \.1).map { $0.0 }
print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.")
freeCourtPerRotation[rotationIndex]?.append(courtIndex)
}
}
if let first = availableMatchs.first {
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: minimumTargetedEndDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.count < availableCourts {
dispatchCourts(availableCourts: availableCourts, courts: freeCourts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: minimumTargetedEndDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
}
}
if freeCourtPerRotation[rotationIndex]?.count == availableCourts {
print("All courts in rotation \(rotationIndex) are free")
}
}
@ -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)
}
}

@ -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
}
}
}
@ -221,10 +220,6 @@ final class PlayerRegistration: ModelObject, Storable {
}
}
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
}
}

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

@ -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<String> = Set<String>(arrayOfIds.sorted())
let searchedIds = Set<String>(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"

@ -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<String> = Set<String>()
let rounds = rounds()
let final = rounds.last?.playedMatches().last
if let winner = final?.winningTeamId {
teams[1] = [winner]
ids.insert(winner)
}
if let finalist = final?.losingTeamId {
teams[2] = [finalist]
ids.insert(finalist)
}
let 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..<groupStageTeams.count {
teams[groupStage.index * groupStage.size + 1 + teamIndex] = [groupStageTeams[teamIndex].id]
}
}
} else {
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 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)
}
print("round", round.roundTitle())
let rounds = round.loserRoundsAndChildren().filter { $0.isRankDisabled() == false && $0.hasNextRound() == false }
print(rounds.count, rounds.map { $0.roundTitle() })
return rounds
}.compactMap({ $0 })
others.forEach { round in
print("round", round.roundTitle())
if let interval = round.seedInterval() {
print("interval", interval.localizedInterval())
let playedMatches = round.playedMatches().filter { $0.disabled == false || $0.isReady() }
print("playedMatches", playedMatches.count)
let winners = playedMatches.compactMap({ $0.winningTeamId }).filter({ ids.contains($0) == false })
print("winners", winners.count)
let losers = playedMatches.compactMap({ $0.losingTeamId }).filter({ ids.contains($0) == false })
print("losers", losers.count)
if winners.isEmpty {
let disabledIds = playedMatches.flatMap({ $0.teamScores.compactMap({ $0.teamRegistration }) }).filter({ ids.contains($0) == false })
if disabledIds.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: disabledIds)
teams[interval.last] = disabledIds
let teamNames : [String] = disabledIds.compactMap {
let t : TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners.isEmpty", "\(interval.last) : ", teamNames)
disabledIds.forEach {
ids.insert($0)
let others: [Round] = rounds.flatMap { round in
let losers = round.losers()
let minimumFinalPosition = round.seedInterval()?.last ?? teamCount
if teams[minimumFinalPosition] == nil {
teams[minimumFinalPosition] = losers.map { $0.id }
} else {
teams[minimumFinalPosition]?.append(contentsOf: losers.map { $0.id })
}
print("round", round.roundTitle())
let rounds = round.loserRoundsAndChildren().filter { $0.isRankDisabled() == false && $0.hasNextRound() == false }
print(rounds.count, rounds.map { $0.roundTitle() })
return rounds
}.compactMap({ $0 })
others.forEach { round in
print("round", round.roundTitle())
if let interval = round.seedInterval() {
print("interval", interval.localizedInterval())
let playedMatches = round.playedMatches().filter { $0.disabled == false || $0.isReady() }
print("playedMatches", playedMatches.count)
let winners = playedMatches.compactMap({ $0.winningTeamId }).filter({ ids.contains($0) == false })
print("winners", winners.count)
let losers = playedMatches.compactMap({ $0.losingTeamId }).filter({ ids.contains($0) == false })
print("losers", losers.count)
if winners.isEmpty {
let disabledIds = playedMatches.flatMap({ $0.teamScores.compactMap({ $0.teamRegistration }) }).filter({ ids.contains($0) == false })
if disabledIds.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: disabledIds)
teams[interval.last] = disabledIds
let teamNames : [String] = disabledIds.compactMap {
let t : TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners.isEmpty", "\(interval.last) : ", teamNames)
disabledIds.forEach {
ids.insert($0)
}
}
} else {
if winners.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: winners)
teams[interval.first + winners.count - 1] = winners
let teamNames : [String] = winners.compactMap {
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners", "\(interval.last + winners.count - 1) : ", teamNames)
winners.forEach { ids.insert($0) }
}
if losers.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: losers)
teams[interval.first + winners.count] = losers
let loserTeamNames : [String] = losers.compactMap {
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("losers", "\(interval.first + winners.count) : ", loserTeamNames)
losers.forEach { ids.insert($0) }
}
}
} else {
if winners.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: winners)
teams[interval.first + winners.count - 1] = winners
let teamNames : [String] = winners.compactMap {
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners", "\(interval.last + winners.count - 1) : ", teamNames)
winners.forEach { ids.insert($0) }
}
}
if let groupStageLoserBracketPlayedMatches = groupStageLoserBracket()?.playedMatches() {
groupStageLoserBracketPlayedMatches.forEach({ match in
if match.hasEnded() {
let sameMatchIndexCount = groupStageLoserBracketPlayedMatches.filter({ $0.index == match.index }).count
teams.setOrAppend(match.winningTeamId, at: match.index)
teams.setOrAppend(match.losingTeamId, at: match.index + sameMatchIndexCount)
}
})
}
if losers.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: losers)
teams[interval.first + winners.count] = losers
let loserTeamNames : [String] = losers.compactMap {
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("losers", "\(interval.first + winners.count) : ", loserTeamNames)
losers.forEach { ids.insert($0) }
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]
}
}
}
}
}
if let groupStageLoserBracketPlayedMatches = groupStageLoserBracket()?.playedMatches() {
groupStageLoserBracketPlayedMatches.forEach({ match in
if match.hasEnded() {
let sameMatchIndexCount = groupStageLoserBracketPlayedMatches.filter({ $0.index == match.index }).count
teams.setOrAppend(match.winningTeamId, at: match.index)
teams.setOrAppend(match.losingTeamId, at: match.index + sameMatchIndexCount)
}
})
return teams
}
func setRankings(finalRanks: [Int: [String]]) async -> [Int: [TeamRegistration]] {
var rankings: [Int: [TeamRegistration]] = [:]
finalRanks.keys.sorted().forEach { rank in
if let rankedTeamIds = finalRanks[rank] {
let teams: [TeamRegistration] = rankedTeamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) }
rankings[rank] = teams
}
}
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]
}
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<TeamRegistration>] {
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<TeamRegistration>] = [.keyPath(\.weight), .keyPath(\.registrationDate!)]
private let _currentSelectionSorting : [MySortDescriptor<TeamRegistration>] = [.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..<teamsPerGroupStage {
let gs = GroupStage(tournament: id, index: i, size: groupStageCount, step: lastStep)
do {
try tournamentStore.groupStages.addOrUpdate(instance: gs)
} catch {
Logger.error(error)
}
}
groupStages(atStep: 1).forEach { $0.buildMatches() }
}
func lastStep() -> Int {
self.tournamentStore.groupStages.sorted(by: \.step).last?.step ?? 0
}
func generateSmartLoserGroupStageBracket() {
guard let groupStageLoserBracket = groupStageLoserBracket() else { return }
for i in qualifiedPerGroupStage..<teamsPerGroupStage {
groupStages().chunked(into: 2).forEach { gss in
let placeCount = i * 2 + 1
let match = Match(round: groupStageLoserBracket.id, index: placeCount, matchFormat: groupStageLoserBracket.matchFormat)
match.name = "\(placeCount)\(placeCount.ordinalFormattedSuffix(feminine: true)) place"
do {
try tournamentStore.matches.addOrUpdate(instance: match)
} catch {
Logger.error(error)
}
if let gs1 = gss.first, let gs2 = gss.last, let score1 = gs1.teams(true)[safe: i], let score2 = gs2.teams(true)[safe: i] {
print("rang \(i)")
print(score1.teamLabel(.short), "vs", score2.teamLabel(.short))
match.setLuckyLoser(team: score1, teamPosition: .one)
match.setLuckyLoser(team: score2, teamPosition: .two)
}
}
}
}
func updateTournamentState() {
Task {
if hasEnded() {
let fr = await finalRanking()
_ = await setRankings(finalRanks: fr)
}
}
}
func allLoserRoundMatches() -> [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)
}

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

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

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

@ -100,7 +100,7 @@ print("Running in Release mode")
//try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}

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

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

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

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

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

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

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

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

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

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

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

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

@ -89,6 +89,7 @@ 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 {
@ -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()
}
}
}
}

@ -11,36 +11,77 @@ import LeStorage
struct CashierSettingsView: View {
@EnvironmentObject var dataStore: DataStore
var tournaments: [Tournament]
init(tournaments: [Tournament]) {
self.tournaments = tournaments
}
@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]
init(tournament: Tournament) {
self.tournaments = [tournament]
self.tournament = tournament
_entryFee = State(wrappedValue: tournament.entryFee)
}
var body: some View {
List {
Section {
RowButtonView("Tout le monde a réglé", role: .destructive) {
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.")
}
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)
}
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) {
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 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 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)
}
}
}

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

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

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

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

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

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

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

@ -6,6 +6,7 @@
//
import SwiftUI
import TipKit
struct GenericDestinationPickerView<T: Identifiable & Selectable & Equatable >: View {
@EnvironmentObject var dataStore: DataStore
@ -49,6 +50,9 @@ struct GenericDestinationPickerView<T: Identifiable & Selectable & Equatable >:
.contentShape(Capsule())
}
}
.selectableTipViewModifier(selectable: destination) {
selectedDestination = destination
}
.padding()
.background {
Capsule()

@ -20,6 +20,7 @@ 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)
@ -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)
}

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

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

@ -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)
@ -97,6 +97,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 {
RowButtonView("delete all group stages") {

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

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

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

@ -93,7 +93,7 @@ struct MatchDateView: View {
.foregroundStyle(Color.master)
.underline()
} else {
Text("en attente")
Text("démarrer")
.foregroundStyle(Color.master)
.underline()
}

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

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

@ -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..<club.courtCount, id: \.self) { courtIndex in
Text(club.courtName(atIndex: courtIndex)) .tag(MatchFieldSetup.field(courtIndex))
}
} else if let tournament = match.currentTournament() {
if let tournament = match.currentTournament() {
ForEach(0..<tournament.courtCount, id: \.self) { courtIndex in
Text(tournament.courtName(atIndex: courtIndex)) .tag(MatchFieldSetup.field(courtIndex))
}

@ -28,7 +28,7 @@ struct MatchSummaryView: View {
self.color = Color(white: 0.9)
if let groupStage = match.groupStageObject {
self.roundTitle = groupStage.groupStageTitle()
self.roundTitle = groupStage.groupStageTitle(.title)
} else if let round = match.roundObject {
self.roundTitle = round.roundTitle(matchViewStyle == .feedStyle ? .wide : .short)
} else {
@ -57,7 +57,7 @@ struct MatchSummaryView: View {
}
}
Spacer()
if let courtName, matchViewStyle != .feedStyle {
if let courtName {
Spacer()
Text(courtName)
.foregroundStyle(.gray)

@ -23,9 +23,10 @@ struct ActivityView: View {
@State private var presentClubSearchView: Bool = false
@State private var quickAccessScreen: QuickAccessScreen? = nil
@State private var displaySearchView: Bool = false
@State private var pasteString: String? = nil
enum QuickAccessScreen : Identifiable, Hashable {
case inscription(pasteString: String)
case inscription
var id: String {
switch self {
@ -75,13 +76,29 @@ struct ActivityView: View {
@ViewBuilder
private func _pasteView() -> 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,13 +312,24 @@ 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 {
@ -305,14 +337,16 @@ struct ActivityView: View {
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)

@ -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..<tournamentHolder.dayDuration).map({ dayDuration in
(tournamentHolder.startDate.dayInt + dayDuration, tournamentHolder.tournaments.count)
(tournamentHolder.startDate.dayInt + dayDuration, tournamentHolder.tournaments.filter({ build in
federalDataViewModel.buildIsValid(build)
}).count)
})
}
counts = Dictionary(mappedItems, uniquingKeysWith: +)

@ -33,7 +33,7 @@ struct EventListView: View {
HStack {
Text(section.monthYearFormatted)
Spacer()
let count = _tournaments.map { $0.tournaments.count }.reduce(0,+)
let count = federalDataViewModel.countForTournamentBuilds(from: _tournaments)
Text("\(count.formatted()) tournoi" + count.pluralSuffix)
}
}
@ -52,7 +52,7 @@ struct EventListView: View {
HStack {
Text(section.monthYearFormatted)
Spacer()
let count = _tournaments.map { $0.tournaments.count }.reduce(0,+)
let count = federalDataViewModel.countForTournamentBuilds(from: _tournaments)
Text("\(count.formatted()) tournoi" + count.pluralSuffix)
}
}

@ -23,15 +23,40 @@ struct TournamentLookUpView: View {
@State private var requestedToGetAllPages: Bool = false
@State private var revealSearchParameters: Bool = true
@State private var presentAlert: Bool = false
@State private var confirmSearch: Bool = false
var tournaments: [FederalTournament] {
federalDataViewModel.searchedFederalTournaments
}
var showLastError: Binding<Bool> {
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: ", "))
}
}

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

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

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

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

@ -40,6 +40,7 @@ struct PlanningByCourtView: View {
var body: some View {
List {
_byCourtView()
.id(selectedCourt)
}
.overlay {
if matches.allSatisfy({ $0.startDate == nil }) {

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

@ -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<ScheduleDestination?>) {
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: ", "))
}
}

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

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

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

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

@ -13,6 +13,14 @@ 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 {
@ -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 {

@ -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,9 +346,16 @@ 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)
}

@ -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,73 +42,76 @@ 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 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 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 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 tournamentPlayed = player.tournamentPlayed, tournamentPlayed > 0 {
HStack(alignment: .lastTextBaseline, spacing: 0) {
Text(tournamentPlayed.formatted()).font(.title3)
Text(" tournoi" + tournamentPlayed.pluralSuffix).font(.caption)
}
}
}
}
.lineLimit(1)
.lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) {
Text("(")
Text(assimilatedAsMaleRank.formatted())
VStack(alignment: .leading, spacing: 0) {
Text("équivalence")
Text("messieurs")
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)
Text(")").font(.title3)
}
}
HStack {
Text(player.formattedLicense())
if let computedAge = player.computedAge {
Text(computedAge.formatted() + " ans")
HStack {
Text(player.formattedLicense())
if let computedAge = player.computedAge {
Text(computedAge.formatted() + " ans")
}
}
.font(.caption)
if let clubName = player.clubName {
Text(clubName)
.font(.caption)
}
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)
}
}
}

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

@ -63,7 +63,7 @@ struct TournamentFilterView: View {
}
}
} label: {
Text(level.localizedLabel(.title))
Text(level.localizedLevelLabel(.title))
}
}
} header: {

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

@ -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
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 teamIndex: Int? {
team.tournamentObject()?.indexOf(team: team)
}
var displayWeight: Bool {
team.tournamentObject()?.hideWeight() == false
}
var body: some View {

@ -21,6 +21,7 @@ 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<Bool> {
Binding {
@ -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 {

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

@ -85,11 +85,18 @@ struct FileImportView: View {
@State private var presentFormatHelperView: Bool = false
@State private var validatedTournamentIds: Set<String> = 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())
}

@ -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<ImportedPlayer>
private var fetchRequest: FetchRequest<ImportedPlayer>
private var fetchPlayers: FetchedResults<ImportedPlayer> { fetchRequest.wrappedValue }
var tournament: Tournament
var cancelShouldDismiss: Bool = false
@ -41,6 +40,12 @@ 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> = ImportedPlayer.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)]
request.fetchLimit = 1000
if let pasteString {
_pasteString = .init(wrappedValue: pasteString)
_fetchPlayers = FetchRequest<ImportedPlayer>(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<PlayerRegistration>()
createdPlayerIds = Set<String>()
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.")
@ -217,10 +302,6 @@ struct AddTeamView: View {
return tournament.tournamentCategory.playerFilterOption
}
private func _searchSource() -> String? {
selectionSearchField ?? pasteString
}
private func _currentSelection() -> Set<PlayerRegistration> {
var currentSelection = Set<PlayerRegistration>()
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,15 +549,21 @@ 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") {
@ -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?
"""
]

@ -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")
}
}
})
@ -321,14 +319,6 @@ struct BroadcastView: View {
}
}
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 }) {

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

@ -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
}
Button("Annuler", role: .cancel) {
loserBracketMode = tournament.loserBracketMode
}
Section {
TextField("Nom du tournoi", text: $tournamentName, axis: .vertical)
.lineLimit(2)
.frame(maxWidth: .infinity)
.keyboardType(.alphabet)
.focused($focusedField, equals: ._name)
})
.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)
}
}

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

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

@ -19,6 +19,7 @@ struct TableStructureView: View {
@State private var qualifiedPerGroupStage: Int = 0
@State private var groupStageAdditionalQualified: Int = 0
@State private var updatedElements: Set<StructureElement> = Set()
@State private var structurePreset: PadelTournamentStructurePreset = .manual
@FocusState private var stepperFieldIsFocused: Bool
var qualifiedFromGroupStage: Int {
@ -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()

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

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

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

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

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

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

Loading…
Cancel
Save