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. 301
      PadelClub/Data/MatchScheduler.swift
  9. 16
      PadelClub/Data/PlayerRegistration.swift
  10. 3
      PadelClub/Data/Round.swift
  11. 12
      PadelClub/Data/TeamRegistration.swift
  12. 264
      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. 13
      PadelClub/Views/Cashier/CashierDetailView.swift
  30. 115
      PadelClub/Views/Cashier/CashierSettingsView.swift
  31. 93
      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. 12
      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. 25
      PadelClub/Views/GroupStage/GroupStageView.swift
  42. 28
      PadelClub/Views/GroupStage/GroupStagesSettingsView.swift
  43. 13
      PadelClub/Views/GroupStage/GroupStagesView.swift
  44. 36
      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. 10
      PadelClub/Views/Match/Components/PlayerBlockView.swift
  49. 12
      PadelClub/Views/Match/MatchDetailView.swift
  50. 4
      PadelClub/Views/Match/MatchSummaryView.swift
  51. 76
      PadelClub/Views/Navigation/Agenda/ActivityView.swift
  52. 8
      PadelClub/Views/Navigation/Agenda/CalendarView.swift
  53. 4
      PadelClub/Views/Navigation/Agenda/EventListView.swift
  54. 67
      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. 13
      PadelClub/Views/Player/PlayerDetailView.swift
  66. 79
      PadelClub/Views/Round/LoserRoundSettingsView.swift
  67. 30
      PadelClub/Views/Round/RoundView.swift
  68. 6
      PadelClub/Views/Shared/ImportedPlayerView.swift
  69. 20
      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. 20
      PadelClub/Views/Team/TeamRowView.swift
  75. 18
      PadelClub/Views/Tournament/FileImportView.swift
  76. 282
      PadelClub/Views/Tournament/Screen/AddTeamView.swift
  77. 24
      PadelClub/Views/Tournament/Screen/BroadcastView.swift
  78. 19
      PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift
  79. 147
      PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift
  80. 11
      PadelClub/Views/Tournament/Screen/Components/TournamentLevelPickerView.swift
  81. 25
      PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift
  82. 96
      PadelClub/Views/Tournament/Screen/TableStructureView.swift
  83. 8
      PadelClub/Views/Tournament/Screen/TournamentCashierView.swift
  84. 25
      PadelClub/Views/Tournament/Screen/TournamentRankView.swift
  85. 29
      PadelClub/Views/Tournament/Shared/TournamentCellView.swift
  86. 42
      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 */; }; FFB1C98B2C10255100B154A7 /* TournamentBroadcastRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1C98A2C10255100B154A7 /* TournamentBroadcastRowView.swift */; };
FFB9C8712BBADDE200A0EF4F /* Selectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB9C8702BBADDE200A0EF4F /* Selectable.swift */; }; FFB9C8712BBADDE200A0EF4F /* Selectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB9C8702BBADDE200A0EF4F /* Selectable.swift */; };
FFB9C8752BBADDF700A0EF4F /* SeedInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB9C8742BBADDF700A0EF4F /* SeedInterval.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 */; }; FFBF065C2BBD2657009D6715 /* GroupStageTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065B2BBD2657009D6715 /* GroupStageTeamView.swift */; };
FFBF065E2BBD8040009D6715 /* MatchListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065D2BBD8040009D6715 /* MatchListView.swift */; }; FFBF065E2BBD8040009D6715 /* MatchListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065D2BBD8040009D6715 /* MatchListView.swift */; };
FFBF06602BBD9F6D009D6715 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065F2BBD9F6D009D6715 /* NavigationViewModel.swift */; }; FFBF06602BBD9F6D009D6715 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBF065F2BBD9F6D009D6715 /* NavigationViewModel.swift */; };
@ -2671,6 +2672,7 @@
FF4CBFF82C996C0600151637 /* TabItemModifier.swift in Sources */, FF4CBFF82C996C0600151637 /* TabItemModifier.swift in Sources */,
FF4CBFF92C996C0600151637 /* DeferredViewModifier.swift in Sources */, FF4CBFF92C996C0600151637 /* DeferredViewModifier.swift in Sources */,
FF4CBFFA2C996C0600151637 /* TournamentScheduleView.swift in Sources */, FF4CBFFA2C996C0600151637 /* TournamentScheduleView.swift in Sources */,
FFBA2D2D2CA2CE9E00D5BBDD /* CodingContainer+Extensions.swift in Sources */,
FF4CBFFB2C996C0600151637 /* MatchFormatStorageView.swift in Sources */, FF4CBFFB2C996C0600151637 /* MatchFormatStorageView.swift in Sources */,
FF4CBFFC2C996C0600151637 /* UmpireView.swift in Sources */, FF4CBFFC2C996C0600151637 /* UmpireView.swift in Sources */,
FF4CBFFD2C996C0600151637 /* User.swift in Sources */, FF4CBFFD2C996C0600151637 /* User.swift in Sources */,
@ -3137,6 +3139,7 @@
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
DEVELOPMENT_TEAM = BQ3Y44M3Q6; DEVELOPMENT_TEAM = BQ3Y44M3Q6;
GCC_OPTIMIZATION_LEVEL = 0;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PadelClub/Info.plist; INFOPLIST_FILE = PadelClub/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Padel Club"; INFOPLIST_KEY_CFBundleDisplayName = "Padel Club";
@ -3155,7 +3158,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.12; MARKETING_VERSION = 1.0.17;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3180,6 +3183,7 @@
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
DEVELOPMENT_TEAM = BQ3Y44M3Q6; DEVELOPMENT_TEAM = BQ3Y44M3Q6;
GCC_OPTIMIZATION_LEVEL = 0;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PadelClub/Info.plist; INFOPLIST_FILE = PadelClub/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Padel Club"; INFOPLIST_KEY_CFBundleDisplayName = "Padel Club";
@ -3198,7 +3202,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.12; MARKETING_VERSION = 1.0.17;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3291,7 +3295,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 12;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
@ -3313,7 +3317,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.10; MARKETING_VERSION = 1.0.15;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3333,7 +3337,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 12;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
DEVELOPMENT_TEAM = BQ3Y44M3Q6; DEVELOPMENT_TEAM = BQ3Y44M3Q6;
@ -3354,7 +3358,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.10; MARKETING_VERSION = 1.0.15;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3375,7 +3379,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
@ -3397,7 +3401,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.9; MARKETING_VERSION = 1.0.14;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3417,7 +3421,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
DEVELOPMENT_TEAM = BQ3Y44M3Q6; DEVELOPMENT_TEAM = BQ3Y44M3Q6;
@ -3438,7 +3442,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.9; MARKETING_VERSION = 1.0.14;
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

@ -74,7 +74,8 @@ extension ImportedPlayer: PlayerHolder {
firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true 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) var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ") trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ") trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ")
@ -122,6 +123,10 @@ extension ImportedPlayer: PlayerHolder {
func getProgression() -> Int { func getProgression() -> Int {
return Int(progression) return Int(progression)
} }
func getComputedRank() -> Int? {
nil
}
} }
fileprivate extension Int { fileprivate extension Int {

@ -210,9 +210,17 @@ extension FederalTournament: FederalTournamentHolder {
nomClub ?? villeEngagement ?? installation?.nom ?? "" 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 // MARK: - CategorieAge

@ -14,9 +14,11 @@ protocol FederalTournamentHolder {
var codeClub: String? { get } var codeClub: String? { get }
var tournaments: [any TournamentBuildHolder] { get } var tournaments: [any TournamentBuildHolder] { get }
func clubLabel() -> String func clubLabel() -> String
func subtitleLabel() -> String func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String
var dayDuration: Int { get } var dayDuration: Int { get }
var dayPeriod: DayPeriod { get } var dayPeriod: DayPeriod { get }
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool
} }
extension FederalTournamentHolder { extension FederalTournamentHolder {

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

@ -24,6 +24,7 @@ final class GroupStage: ModelObject, Storable {
private var format: MatchFormat? private var format: MatchFormat?
var startDate: Date? var startDate: Date?
var name: String? var name: String?
var step: Int = 0
var matchFormat: MatchFormat { var matchFormat: MatchFormat {
get { 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.tournament = tournament
self.index = index self.index = index
self.size = size self.size = size
self.format = matchFormat self.format = matchFormat
self.startDate = startDate self.startDate = startDate
self.name = name self.name = name
self.step = step
} }
var tournamentStore: TournamentStore { var tournamentStore: TournamentStore {
@ -50,7 +52,7 @@ final class GroupStage: ModelObject, Storable {
// MARK: - Computed dependencies // MARK: - Computed dependencies
func _matches() -> [Match] { 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 } // Store.main.filter { $0.groupStage == self.id }
} }
@ -61,19 +63,34 @@ final class GroupStage: ModelObject, Storable {
// MARK: - // MARK: -
func teamAt(groupStagePosition: Int) -> TeamRegistration? { 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 { func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let name { return name } if let name { return name }
var stepLabel = ""
if step > 0 {
stepLabel = " (" + (step + 1).ordinalFormatted(feminine: true) + " phase)"
}
switch displayStyle { switch displayStyle {
case .wide, .title: case .title:
return "Poule \(index + 1)" + stepLabel
case .wide:
return "Poule \(index + 1)" return "Poule \(index + 1)"
case .short: case .short:
return "#\(index + 1)" return "#\(index + 1)"
} }
} }
var computedOrder: Int {
index + step * 100
}
func isRunning() -> Bool { // at least a match has started func isRunning() -> Bool { // at least a match has started
_matches().anySatisfy({ $0.isRunning() }) _matches().anySatisfy({ $0.isRunning() })
} }
@ -145,6 +162,19 @@ final class GroupStage: ModelObject, Storable {
} catch { } catch {
Logger.error(error) 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? { 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 return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate
} }
@ -257,16 +287,20 @@ final class GroupStage: ModelObject, Storable {
case 4: case 4:
return [2, 3, 1, 4, 5, 0] return [2, 3, 1, 4, 5, 0]
case 5: case 5:
return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9] // return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9]
// return [3, 5, 8, 2, 6, 7, 1, 9, 4, 0] return [3, 5, 8, 2, 6, 1, 9, 4, 7, 0]
case 6: case 6:
return [1, 7, 13, 11, 3, 6, 10, 2, 8, 12, 5, 4, 9, 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] return [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0]
default: default:
return [] return []
} }
} }
func indexOf(_ matchIndex: Int) -> Int {
_matchOrder().firstIndex(of: matchIndex) ?? matchIndex
}
private func _matchUp(for matchIndex: Int) -> [Int] { private func _matchUp(for matchIndex: Int) -> [Int] {
Array((0..<size).combinations(ofCount: 2))[safe: matchIndex] ?? [] Array((0..<size).combinations(ofCount: 2))[safe: matchIndex] ?? []
} }
@ -274,7 +308,7 @@ final class GroupStage: ModelObject, Storable {
func localizedMatchUpLabel(for matchIndex: Int) -> String { func localizedMatchUpLabel(for matchIndex: Int) -> String {
let matchUp = _matchUp(for: matchIndex) let matchUp = _matchUp(for: matchIndex)
if let index = matchUp.first, let index2 = matchUp.last { if let index = matchUp.first, let index2 = matchUp.last {
return "#\(index + 1) contre #\(index2 + 1)" return "#\(index + 1) vs #\(index2 + 1)"
} else { } else {
return "--" return "--"
} }
@ -326,20 +360,23 @@ final class GroupStage: ModelObject, Storable {
} }
func unsortedTeams() -> [TeamRegistration] { 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 } return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil }
} }
func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] { func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] {
if sortedByScore { if sortedByScore {
return unsortedTeams().compactMap({ team in 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 }).sorted { (lhs, rhs) in
let predicates: [TeamScoreAreInIncreasingOrder] = [ let predicates: [TeamScoreAreInIncreasingOrder] = [
{ $0.wins < $1.wins }, { $0.wins < $1.wins },
{ $0.setDifference < $1.setDifference }, { $0.setDifference < $1.setDifference },
{ $0.gameDifference < $1.gameDifference}, { $0.gameDifference < $1.gameDifference},
{ self._headToHead($0.team, $1.team) }, { self._headToHead($0.team, $1.team) },
{ $0.team.groupStagePosition! > $1.team.groupStagePosition! } { [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! }
] ]
for predicate in predicates { for predicate in predicates {
@ -397,6 +434,19 @@ final class GroupStage: ModelObject, Storable {
self.tournamentStore.matches.deleteDependencies(matches) 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 { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) 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(format, forKey: ._format)
try container.encode(startDate, forKey: ._startDate) try container.encode(startDate, forKey: ._startDate)
try container.encode(name, forKey: ._name) try container.encode(name, forKey: ._name)
try container.encode(step, forKey: ._step)
} }
func insertOnServer() { func insertOnServer() {
@ -428,6 +478,7 @@ extension GroupStage {
case _format = "format" case _format = "format"
case _startDate = "startDate" case _startDate = "startDate"
case _name = "name" case _name = "name"
case _step = "step"
} }
} }

@ -408,7 +408,7 @@ defer {
} }
func next() -> Match? { 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 return matches.sorted(by: \.index).first
} }
@ -435,6 +435,10 @@ defer {
else { return nil } else { return nil }
} }
func roundAndMatchTitle() -> String {
[roundTitle(), matchTitle()].compactMap({ $0 }).joined(separator: " ")
}
func topPreviousRoundMatchIndex() -> Int { func topPreviousRoundMatchIndex() -> Int {
return index * 2 + 1 return index * 2 + 1
} }
@ -470,8 +474,11 @@ defer {
} }
var computedOrder: Int { var computedOrder: Int {
if let groupStageObject {
return (groupStageObject.index + 1) * 100 + groupStageObject.indexOf(index)
}
guard let roundObject else { return 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] { func previousMatches() -> [Match] {
@ -510,6 +517,7 @@ defer {
losingTeamId = teamScoreWalkout.teamRegistration losingTeamId = teamScoreWalkout.teamRegistration
groupStageObject?.updateGroupStageState() groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState() roundObject?.updateTournamentState()
currentTournament()?.updateTournamentState()
updateFollowingMatchTeamScore() updateFollowingMatchTeamScore()
} }
@ -535,6 +543,7 @@ defer {
groupStageObject?.updateGroupStageState() groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState() roundObject?.updateTournamentState()
currentTournament()?.updateTournamentState()
updateFollowingMatchTeamScore() updateFollowingMatchTeamScore()
} }

@ -93,9 +93,9 @@ final class MatchScheduler : ModelObject, Storable {
} }
@discardableResult @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() let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue()
var groupStages: [GroupStage] = tournament.groupStages() var groupStages: [GroupStage] = tournament.groupStages(atStep: step)
if let specificGroupStage { if let specificGroupStage {
groupStages = [specificGroupStage] groupStages = [specificGroupStage]
} }
@ -108,7 +108,7 @@ final class MatchScheduler : ModelObject, Storable {
$0.confirmed = false $0.confirmed = false
}) })
var lastDate : Date = tournament.startDate var lastDate : Date = startDate ?? tournament.startDate
let times = Set(groupStages.compactMap { $0.startDate }).sorted() let times = Set(groupStages.compactMap { $0.startDate }).sorted()
if let first = times.first { if let first = times.first {
@ -123,7 +123,9 @@ final class MatchScheduler : ModelObject, Storable {
} }
times.forEach({ time in times.forEach({ time in
if lastDate.isEarlierThan(time) {
lastDate = time lastDate = time
}
let groups = groupStages.filter({ $0.startDate == time }) let groups = groupStages.filter({ $0.startDate == time })
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) 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 // Get the maximum count of matches in any group
let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 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 let flattenedMatches = (0..<maxMatchesCount).flatMap { index in
_groupStages.compactMap { group 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() let playedMatches = group.playedMatches()
return playedMatches.indices.contains(index) ? playedMatches[index] : nil return playedMatches.indices.contains(index) ? playedMatches[index] : nil
} }
} }
var slots = [GroupStageTimeMatch]() var slots = [GroupStageTimeMatch]()
var availableMatchs = flattenedMatches var availableMatches = flattenedMatches
var rotationIndex = 0 var rotationIndex = 0
var teamsPerRotation = [Int: [String]]() var teamsPerRotation = [Int: [String]]() // Tracks teams assigned to each rotation
var freeCourtPerRotation = [Int: [Int]]() var freeCourtPerRotation = [Int: [Int]]() // Tracks free courts per rotation
var groupLastRotation = [Int: Int]() var groupLastRotation = [Int: Int]() // Tracks the last rotation each group was involved in
let courtsUnavailability = courtsUnavailability let courtsUnavailability = courtsUnavailability
while slots.count < flattenedMatches.count { while slots.count < flattenedMatches.count {
print("Starting rotation \(rotationIndex) with \(availableMatches.count) matches left")
teamsPerRotation[rotationIndex] = [] teamsPerRotation[rotationIndex] = []
freeCourtPerRotation[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: +) let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +)
var rotationMatches = Array(availableMatchs.filter({ match in var rotationMatches = Array(availableMatches.filter({ match in
teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true // 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)) }).prefix(numberOfCourtsAvailablePerRotation))
if rotationIndex > 0 { if rotationIndex > 0 {
@ -216,28 +227,42 @@ final class MatchScheduler : ModelObject, Storable {
} }
(0..<numberOfCourtsAvailablePerRotation).forEach { courtIndex in (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 if let first = rotationMatches.first(where: { match in
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration) 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 rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability) 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 return false
} else {
return teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true
} }
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamId($0) })
if !teamsAvailable {
print("Teams from match \(match.roundAndMatchTitle()) are already scheduled in this rotation")
return false
}
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) 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) slots.append(timeMatch)
teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds()) teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds())
rotationMatches.removeAll(where: { $0.id == first.id }) 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 { if let index = first.groupStageObject?.index {
groupLastRotation[index] = rotationIndex groupLastRotation[index] = rotationIndex
} }
} else { } else {
print("No available matches for court \(courtIndex) in rotation \(rotationIndex), adding to free court list")
freeCourtPerRotation[rotationIndex]!.append(courtIndex) freeCourtPerRotation[rotationIndex]!.append(courtIndex)
} }
} }
@ -245,6 +270,9 @@ final class MatchScheduler : ModelObject, Storable {
rotationIndex += 1 rotationIndex += 1
} }
print("All matches scheduled. Total rotations: \(rotationIndex)")
// Organize slots and ensure courts are randomized or sorted
var organizedSlots = [GroupStageTimeMatch]() var organizedSlots = [GroupStageTimeMatch]()
for i in 0..<rotationIndex { for i in 0..<rotationIndex {
let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted() let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted()
@ -257,10 +285,15 @@ final class MatchScheduler : ModelObject, Storable {
} }
} }
return GroupStageMatchDispatcher(
return GroupStageMatchDispatcher(timedMatches: organizedSlots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, groupLastRotation: groupLastRotation) timedMatches: organizedSlots,
freeCourtPerRotation: freeCourtPerRotation,
rotationCount: rotationIndex,
groupLastRotation: groupLastRotation
)
} }
func rotationDifference(loserBracket: Bool) -> Int { func rotationDifference(loserBracket: Bool) -> Int {
if loserBracket { if loserBracket {
return loserBracketRotationDifference 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 { 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 { 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 { if targetedStartDate == minimumTargetedEndDate {
print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)")
minimumTargetedEndDate = roundStartDate minimumTargetedEndDate = roundStartDate
} else { } else {
print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)")
minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate)
} }
print("Returning false: Match cannot start earlier than the round start date.")
return false return false
} }
let previousMatches = roundObject.precedentMatches(ofMatch: match) 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 let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) }
previousMatches.map { $0.id }.contains(slot.matchID)
})
if previousMatchSlots.isEmpty { 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 return true
} }
print("Some previous matches are pending, returning false.")
return false return false
} }
if previousMatches.filter({ $0.disabled == false }).count > previousMatchSlots.count { if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count {
if previousMatches.filter({ $0.disabled == false }).anySatisfy({ $0.startDate != nil }) { if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) {
print("Some previous matches started, returning true.")
return true return true
} }
print("Not enough previous matches have started, returning false.")
return false return false
} }
var includeBreakTime = false var includeBreakTime = false
if accountLoserBracketBreakTime && roundObject.isLoserBracket() { if accountLoserBracketBreakTime && roundObject.isLoserBracket() {
includeBreakTime = true includeBreakTime = true
print("Including break time for loser bracket.")
} }
if accountUpperBracketBreakTime && roundObject.isLoserBracket() == false { if accountUpperBracketBreakTime && !roundObject.isLoserBracket() {
includeBreakTime = true 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
}
guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else { if previousMatchIsInPreviousRotation {
print("All previous matches are from earlier rotations, returning true.")
} else {
print("Some previous matches are from the current rotation.")
}
guard let minimumPossibleEndDate = previousMatchSlots.map({
$0.estimatedEndDate(includeBreakTime: includeBreakTime)
}).max() else {
print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).")
return previousMatchIsInPreviousRotation return previousMatchIsInPreviousRotation
} }
if targetedStartDate >= minimumPossibleEndDate { if targetedStartDate >= minimumPossibleEndDate {
if rotationDifferenceIsImportant { if rotationDifferenceIsImportant {
print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).")
return previousMatchIsInPreviousRotation return previousMatchIsInPreviousRotation
} else { } else {
print("Targeted start date is after the minimum possible end date, returning true.")
return true return true
} }
} else { } else {
if targetedStartDate == minimumTargetedEndDate { if targetedStartDate == minimumTargetedEndDate {
print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)")
minimumTargetedEndDate = minimumPossibleEndDate minimumTargetedEndDate = minimumPossibleEndDate
} else { } else {
print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)")
minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate)
} }
print("Targeted start date is before the minimum possible end date, returning false.")
return false return false
} }
} }
func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? { func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? {
slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min() 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 { func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher {
var slots = [TimeMatch]() var slots = [TimeMatch]()
var _startDate: Date? var _startDate: Date?
var rotationIndex = 0 var rotationIndex = 0
@ -377,6 +435,9 @@ final class MatchScheduler : ModelObject, Storable {
let courtsUnavailability = courtsUnavailability let courtsUnavailability = courtsUnavailability
var issueFound: Bool = false 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 flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in
if _startDate == nil { if _startDate == nil {
_startDate = match.startDate _startDate = match.startDate
@ -389,19 +450,16 @@ final class MatchScheduler : ModelObject, Storable {
slots.append(timeMatch) slots.append(timeMatch)
} }
if slots.isEmpty == false { if !slots.isEmpty {
rotationIndex += 1 rotationIndex += 1
} }
var freeCourtPerRotation = [Int: [Int]]() var freeCourtPerRotation = [Int: [Int]]()
let availableCourt = numberOfCourtsAvailablePerRotation let availableCourt = numberOfCourtsAvailablePerRotation
var courts = initialCourts ?? (0..<availableCourt).map { $0 } var courts = initialCourts ?? (0..<availableCourt).map { $0 }
var shouldStartAtDispatcherDate = rotationIndex > 0 var shouldStartAtDispatcherDate = rotationIndex > 0
while availableMatchs.count > 0 && issueFound == false { while !availableMatchs.isEmpty && !issueFound && rotationIndex < 100 {
freeCourtPerRotation[rotationIndex] = [] freeCourtPerRotation[rotationIndex] = []
let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 }) let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 })
var rotationStartDate: Date = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate 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 = rotationIndex == 0 ? courts : (0..<availableCourt).map { $0 }
} }
courts.sort() courts.sort()
print("courts available at rotation \(rotationIndex)", courts)
print("rotationStartDate", rotationStartDate)
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], freeCourtPreviousRotation.count > 0 { // Log courts availability and start date
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") print("Courts available at rotation \(rotationIndex): \(courts)")
let previousPreviousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }) 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 previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) 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 { if let previousEndDate, let previousEndDateNoBreak {
let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate) let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate)
let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak) let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak)
print("difference w break", differenceWithBreak) print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)")
print("difference w/o break", differenceWithoutBreak)
let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60) let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60)
var difference = differenceWithBreak var difference = differenceWithBreak
if differenceWithBreak <= 0 { if differenceWithBreak <= 0 {
difference = differenceWithoutBreak difference = differenceWithoutBreak
} else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds { } else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds {
@ -437,34 +500,35 @@ final class MatchScheduler : ModelObject, Storable {
} }
if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate { if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate {
courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index) courts.removeAll(where: { freeCourtPreviousRotation.contains($0) })
})
freeCourtPerRotation[rotationIndex] = courts freeCourtPerRotation[rotationIndex] = courts
courts = freeCourtPreviousRotation courts = freeCourtPreviousRotation
rotationStartDate = rotationStartDate.addingTimeInterval(-difference) rotationStartDate = rotationStartDate.addingTimeInterval(-difference)
} }
} }
} else if let first = availableMatchs.first { } else if let firstMatch = availableMatchs.first {
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration) let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.count == numberOfCourtsAvailablePerRotation { if courtsUnavailable.count == numberOfCourtsAvailablePerRotation {
print("issue") print("Issue: All courts unavailable in this rotation")
issueFound = true issueFound = true
} else { } else {
courts = Array(Set(courts).subtracting(Set(courtsUnavailable))) 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) dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
rotationIndex += 1 rotationIndex += 1
} }
// Organize matches in slots
var organizedSlots = [TimeMatch]() var organizedSlots = [TimeMatch]()
for i in 0..<rotationIndex { 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 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 { for j in 0..<matches.count {
matches[j].courtIndex = courts[j] 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) 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]?) { 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 matchPerRound = [String: Int]()
var minimumTargetedEndDate: Date = rotationStartDate var minimumTargetedEndDate = rotationStartDate
print("dispatchCourts", courts.sorted(), rotationStartDate, rotationIndex)
// 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() { for (courtPosition, courtIndex) in courts.sorted().enumerated() {
if let first = availableMatchs.first(where: { match in if let firstMatch = availableMatchs.first(where: { match in
print("trying to find a match for \(courtIndex) in \(rotationIndex)") print("Trying to find a match for court \(courtIndex) in rotation \(rotationIndex)")
let roundObject = match.roundObject! let roundObject = match.roundObject!
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability) let duration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
print("courtsUnavailable \(courtsUnavailable)")
if courtPosition >= availableCourts - courtsUnavailable.count { 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 return false
} }
let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) 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 let roundMatchesCount = roundObject.playedMatches().count
if shouldHandleUpperRoundSlice { if shouldHandleUpperRoundSlice {
print("shouldHandleUpperRoundSlice \(roundMatchesCount)") if roundObject.parent == nil && roundMatchesCount > courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) {
if roundObject.parent == nil && roundMatchesCount > courts.count { print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).")
print("roundMatchesCount \(roundMatchesCount) > \(courts.count)")
if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) {
print("return false, \(currentRotationSameRoundMatches) >= \(min(roundMatchesCount / 2, courts.count))")
return false return false
} }
} }
}
//if all is ok, we do a final check to see if the first
let indexInRound = match.indexInRound() 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() {
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) {
guard courtPosition < courts.count - 1, courts.count > 1 else { print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).")
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")
return true return true
} } else {
} print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).")
//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")
return false return false
} }
} }
print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).")
return canBePlayed return canBePlayed
}) { }) {
print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate) print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)")
matchPerRound[firstMatch.roundObject!.id, default: 0] += 1
let timeMatch = TimeMatch(
matchID: firstMatch.id,
rotationIndex: rotationIndex,
courtIndex: courtIndex,
startDate: rotationStartDate,
durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration),
minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime
)
if first.roundObject!.parent == nil {
if let roundIndex = matchPerRound[first.roundObject!.id] {
matchPerRound[first.roundObject!.id] = roundIndex + 1
} else {
matchPerRound[first.roundObject!.id] = 1
}
}
let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: first.matchFormat.breakTime.breakTime)
slots.append(timeMatch) slots.append(timeMatch)
availableMatchs.removeAll(where: { $0.id == first.id }) availableMatchs.removeAll(where: { $0.id == firstMatch.id })
} else { } else {
freeCourtPerRotation[rotationIndex]!.append(courtIndex) print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.")
} freeCourtPerRotation[rotationIndex]?.append(courtIndex)
} }
if freeCourtPerRotation[rotationIndex]!.count == availableCourts {
print("no match found to be put in this rotation, check if we can put anything to another date")
freeCourtPerRotation[rotationIndex] = []
let courtsUsed = getNextEarliestAvailableDate(from: slots)
var freeCourts: [Int] = []
if courtsUsed.isEmpty {
freeCourts = (0..<availableCourts).map { $0 }
} else {
freeCourts = courtsUsed.filter { (courtIndex, availableDate) in
availableDate <= minimumTargetedEndDate
}.sorted(by: \.1).map { $0.0 }
} }
if freeCourtPerRotation[rotationIndex]?.count == availableCourts {
if let first = availableMatchs.first { print("All courts in rotation \(rotationIndex) are free")
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)
}
}
} }
} }
@ -586,16 +628,20 @@ final class MatchScheduler : ModelObject, Storable {
var rounds = [Round]() var rounds = [Round]()
if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() {
rounds.append(groupStageLoserBracketRound)
}
if shouldEndRoundBeforeStartingNext { if shouldEndRoundBeforeStartingNext {
rounds = upperRounds.flatMap { rounds.append(contentsOf: upperRounds.flatMap {
[$0] + $0.loserRoundsAndChildren() [$0] + $0.loserRoundsAndChildren()
} })
} else { } else {
rounds = upperRounds.map { rounds.append(contentsOf: upperRounds.map {
$0 $0
} + upperRounds.flatMap { } + upperRounds.flatMap {
$0.loserRoundsAndChildren() $0.loserRoundsAndChildren()
} })
} }
let flattenedMatches = rounds.flatMap { round in let flattenedMatches = rounds.flatMap { round in
@ -699,6 +745,9 @@ final class MatchScheduler : ModelObject, Storable {
if tournament.groupStageCount > 0 { if tournament.groupStageCount > 0 {
lastDate = updateGroupStageSchedule(tournament: tournament) 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) return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate)
} }
} }

@ -73,6 +73,7 @@ final class PlayerRegistration: ModelObject, Storable {
self.ligueName = importedPlayer.ligueName self.ligueName = importedPlayer.ligueName
self.assimilation = importedPlayer.assimilation self.assimilation = importedPlayer.assimilation
self.source = .frenchFederation self.source = .frenchFederation
self.birthdate = importedPlayer.birthYear
} }
internal init?(federalData: [String], sex: Int, sexUnknown: Bool) { internal init?(federalData: [String], sex: Int, sexUnknown: Bool) {
@ -123,7 +124,6 @@ final class PlayerRegistration: ModelObject, Storable {
var computedAge: Int? { var computedAge: Int? {
if let birthdate { if let birthdate {
let components = birthdate.components(separatedBy: "/") let components = birthdate.components(separatedBy: "/")
if components.count == 3 {
if let age = components.last, let ageInt = Int(age) { if let age = components.last, let ageInt = Int(age) {
let year = Calendar.current.getSportAge() let year = Calendar.current.getSportAge()
@ -138,7 +138,6 @@ final class PlayerRegistration: ModelObject, Storable {
} }
} }
} }
}
return nil return nil
} }
@ -221,10 +220,6 @@ final class PlayerRegistration: ModelObject, Storable {
} }
} }
func getRank() -> Int {
computedRank
}
@MainActor @MainActor
func updateRank(from sources: [CSVParser], lastRank: Int) async throws { func updateRank(from sources: [CSVParser], lastRank: Int) async throws {
if let dataFound = try await history(from: sources) { if let dataFound = try await history(from: sources) {
@ -283,6 +278,11 @@ final class PlayerRegistration: ModelObject, Storable {
} }
func setComputedRank(in tournament: Tournament) { func setComputedRank(in tournament: Tournament) {
if tournament.isAnimation() {
computedRank = rank ?? 0
return
}
let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 70_000 let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 70_000
switch tournament.tournamentCategory { switch tournament.tournamentCategory {
case .men: case .men:
@ -509,4 +509,8 @@ extension PlayerRegistration: PlayerHolder {
func getProgression() -> Int { func getProgression() -> Int {
0 0
} }
func getComputedRank() -> Int? {
computedRank
}
} }

@ -598,9 +598,6 @@ defer {
func deleteLoserBracket() { func deleteLoserBracket() {
do { do {
let loserRounds = loserRounds() let loserRounds = loserRounds()
for loserRound in loserRounds {
try loserRound.deleteDependencies()
}
try self.tournamentStore.rounds.delete(contentOfs: loserRounds) try self.tournamentStore.rounds.delete(contentOfs: loserRounds)
} catch { } catch {
Logger.error(error) Logger.error(error)

@ -208,6 +208,7 @@ final class TeamRegistration: ModelObject, Storable {
} }
func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String { func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String {
if let name { return name }
return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ") 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 arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion })
let ids : Set<String> = Set<String>(arrayOfIds.sorted()) let ids : Set<String> = Set<String>(arrayOfIds.sorted())
let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted()) let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted())
if ids.isEmpty || searchedIds.isEmpty { return false }
return ids.hashValue == searchedIds.hashValue return ids.hashValue == searchedIds.hashValue
} }
@ -511,6 +513,16 @@ final class TeamRegistration: ModelObject, Storable {
return Store.main.findById(tournament) 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 { enum CodingKeys: String, CodingKey {
case _id = "id" case _id = "id"
case _tournament = "tournament" case _tournament = "tournament"

@ -350,13 +350,13 @@ final class Tournament : ModelObject, Storable {
return Array(self.tournamentStore.teamRegistrations) return Array(self.tournamentStore.teamRegistrations)
} }
func groupStages() -> [GroupStage] { func groupStages(atStep step: Int = 0) -> [GroupStage] {
let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id } let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id && $0.step == step }
return groupStages.sorted(by: \.index) return groupStages.sorted(by: \.index)
} }
func allGroupStages() -> [GroupStage] { func allGroupStages() -> [GroupStage] {
return Array(self.tournamentStore.groupStages) return self.tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder)
} }
func allRounds() -> [Round] { func allRounds() -> [Round] {
@ -737,8 +737,8 @@ defer {
closedRegistrationDate != nil closedRegistrationDate != nil
} }
func getActiveGroupStage() -> GroupStage? { func getActiveGroupStage(atStep step: Int = 0) -> GroupStage? {
let groupStages = groupStages() let groupStages = groupStages(atStep: step)
return groupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).first ?? groupStages.first 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 wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending)
let groupStageSpots: Int = self.groupStageSpots() 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 var groupStageTeamCount: Int = groupStageSpots - wcGroupStage.count
if groupStageTeamCount < 0 { groupStageTeamCount = 0 } if groupStageTeamCount < 0 { groupStageTeamCount = 0 }
if bracketSeeds < 0 { bracketSeeds = 0 } if bracketSeeds < 0 { bracketSeeds = 0 }
@ -979,15 +979,39 @@ defer {
return [] return []
} }
return players.filter { player in return players.filter { player in
if player.rank == nil { return false } return isPlayerRankInadequate(player: player)
if player.computedRank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { }
}
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 return true
} else { } else {
return false 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? { func mandatoryRegistrationCloseDate() -> Date? {
switch tournamentLevel { switch tournamentLevel {
case .p500, .p1000, .p1500, .p2000: case .p500, .p1000, .p1500, .p2000:
@ -1037,6 +1061,11 @@ defer {
registrationDate = previousTeamRegistrationDate registrationDate = previousTeamRegistrationDate
} }
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name) 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) teamsToImport.append(newTeam)
} }
} }
@ -1088,8 +1117,8 @@ defer {
return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame
} }
func groupStagesMatches() -> [Match] { func groupStagesMatches(atStep step: Int = 0) -> [Match] {
return self.tournamentStore.matches.filter { $0.groupStage != nil } return groupStages(atStep: step).flatMap({ $0._matches() })
// return Store.main.filter(isIncluded: { $0.groupStage != nil && groupStageIds.contains($0.groupStage!) }) // return Store.main.filter(isIncluded: { $0.groupStage != nil && groupStageIds.contains($0.groupStage!) })
} }
@ -1161,6 +1190,18 @@ defer {
var teams: [Int: [String]] = [:] var teams: [Int: [String]] = [:]
var ids: Set<String> = Set<String>() var ids: Set<String> = Set<String>()
let rounds = rounds() let rounds = rounds()
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 final = rounds.last?.playedMatches().last let final = rounds.last?.playedMatches().last
if let winner = final?.winningTeamId { if let winner = final?.winningTeamId {
teams[1] = [winner] teams[1] = [winner]
@ -1264,10 +1305,40 @@ defer {
} }
} }
} }
}
return teams 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
}
}
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)
}
}
}
do {
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams())
} catch {
Logger.error(error)
}
return rankings
}
func lockRegistration() { func lockRegistration() {
closedRegistrationDate = Date() closedRegistrationDate = Date()
let count = selectedSortedTeams().count let count = selectedSortedTeams().count
@ -1357,10 +1428,14 @@ defer {
} }
func tournamentTitle(_ displayStyle: DisplayStyle = .wide) -> String { func tournamentTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if tournamentLevel == .unlisted, displayStyle == .title, let name { if tournamentLevel == .unlisted, displayStyle == .title {
if let name {
return 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 { if displayStyle == .wide, let name {
return [title, name].joined(separator: " - ") return [title, name].joined(separator: " - ")
} else { } else {
@ -1371,9 +1446,9 @@ defer {
func localizedTournamentType() -> String { func localizedTournamentType() -> String {
switch tournamentLevel { switch tournamentLevel {
case .unlisted: case .unlisted:
return tournamentLevel.localizedLabel(.short) return tournamentLevel.localizedLevelLabel(.short)
default: 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 { func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle { 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) startDate.formatted(date: Date.FormatStyle.DateStyle.complete, time: Date.FormatStyle.TimeStyle.omitted)
case .short: case .short:
startDate.formatted(date: .numeric, time: .omitted) startDate.formatted(date: .numeric, time: .omitted)
@ -1434,8 +1511,8 @@ defer {
} }
} }
func groupStagesAreOver() -> Bool { func groupStagesAreOver(atStep: Int = 0) -> Bool {
let groupStages = groupStages() let groupStages = groupStages(atStep: atStep)
guard groupStages.isEmpty == false else { guard groupStages.isEmpty == false else {
return true return true
} }
@ -1443,6 +1520,13 @@ defer {
//return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified //return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified
} }
func groupStageLoserBracketAreOver() -> Bool {
guard let groupStageLoserBracket = groupStageLoserBracket() else {
return true
}
return groupStageLoserBracket.hasEnded()
}
fileprivate func _paymentMethodMessage() -> String? { fileprivate func _paymentMethodMessage() -> String? {
return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods
} }
@ -1474,13 +1558,27 @@ defer {
return Double(selectedPlayers.filter { $0.hasPaid() }.count) / Double(selectedPlayers.count) 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) typealias TournamentStatus = (label:String, completion: String)
func cashierStatus() async -> TournamentStatus { func cashierStatus() async -> TournamentStatus {
let selectedPlayers = selectedPlayers() 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 label = "\(paid.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs encaissés" let label = "\(filteredPlayers.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs \(wording)\(filteredPlayers.count.pluralSuffix)"
let completion = (Double(paid.count) / Double(selectedPlayers.count)) let completion = (Double(filteredPlayers.count) / Double(selectedPlayers.count))
let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0)))
return TournamentStatus(label: label, completion: completionLabel) return TournamentStatus(label: label, completion: completionLabel)
} }
@ -1570,12 +1668,23 @@ defer {
return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ")
} }
func deleteAndBuildEverything() { func deleteAndBuildEverything(preset: PadelTournamentStructurePreset = .manual) {
resetBracketPosition() resetBracketPosition()
deleteStructure() deleteStructure()
deleteGroupStages() deleteGroupStages()
switch preset {
case .manual:
buildGroupStages() buildGroupStages()
buildBracket() buildBracket()
case .doubleGroupStage:
buildGroupStages()
addNewGroupStageStep()
qualifiedPerGroupStage = 0
groupStageAdditionalQualified = 0
}
} }
func buildGroupStages() { func buildGroupStages() {
@ -1675,7 +1784,7 @@ defer {
func deleteGroupStages() { func deleteGroupStages() {
do { do {
try self.tournamentStore.groupStages.delete(contentOfs: groupStages()) try self.tournamentStore.groupStages.delete(contentOfs: allGroupStages())
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
@ -1761,6 +1870,11 @@ defer {
players.forEach { player in players.forEach { player in
player.teamRegistration = team.id player.teamRegistration = team.id
} }
if isAnimation() {
if team.weight == 0 {
team.weight = unsortedTeams().count
}
}
return team return team
} }
@ -1861,6 +1975,7 @@ defer {
groupStageMatchFormat = groupStageSmartMatchFormat() groupStageMatchFormat = groupStageSmartMatchFormat()
loserBracketMatchFormat = loserBracketSmartMatchFormat(5) loserBracketMatchFormat = loserBracketSmartMatchFormat(5)
matchFormat = roundSmartMatchFormat(5) matchFormat = roundSmartMatchFormat(5)
entryFee = tournamentLevel.entryFee
} }
func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat {
@ -1876,9 +1991,9 @@ defer {
private func _defaultSorting() -> [MySortDescriptor<TeamRegistration>] { private func _defaultSorting() -> [MySortDescriptor<TeamRegistration>] {
switch teamSorting { switch teamSorting {
case .rank: case .rank:
[.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!)] [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.id)]
case .inscriptionDate: 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 && 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] { private func _matchSchedulers() -> [MatchScheduler] {
return self.tournamentStore.matchSchedulers.filter { $0.tournament == self.id } return self.tournamentStore.matchSchedulers.filter { $0.tournament == self.id }
@ -1991,6 +2106,62 @@ defer {
return teamCount - teamsPerGroupStage * groupStageCount + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified + 1 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: - // MARK: -
func insertOnServer() throws { func insertOnServer() throws {
@ -2093,6 +2264,20 @@ extension Tournament: Hashable {
} }
extension Tournament: FederalTournamentHolder { 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? { var codeClub: String? {
club()?.code club()?.code
} }
@ -2103,8 +2288,18 @@ extension Tournament: FederalTournamentHolder {
locationLabel() locationLabel()
} }
func subtitleLabel() -> String { func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
subtitle() 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] { var tournaments: [any TournamentBuildHolder] {
@ -2122,10 +2317,23 @@ extension Tournament: FederalTournamentHolder {
return .weekend 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 { extension Tournament: TournamentBuildHolder {
func buildHolderTitle() -> String { func buildHolderTitle(_ displayStyle: DisplayStyle) -> String {
tournamentTitle(.short) tournamentTitle(.short)
} }

@ -30,8 +30,8 @@ extension Calendar {
let currentYear = component(.year, from: currentDate) let currentYear = component(.year, from: currentDate)
// Define the date components for 1st September and 31st December of the current year // Define the date components for 1st September and 31st December of the current year
var septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) let septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1)
var decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) let decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31)
// Get the actual dates for 1st September and 31st December // Get the actual dates for 1st September and 31st December
let septemberFirst = date(from: septemberFirstComponents)! let septemberFirst = date(from: septemberFirstComponents)!

@ -8,15 +8,15 @@
import Foundation import Foundation
public extension FixedWidthInteger { public extension FixedWidthInteger {
func ordinalFormattedSuffix() -> String { func ordinalFormattedSuffix(feminine: Bool = false) -> String {
switch self { switch self {
case 1: return "er" case 1: return feminine ? "ère" : "er"
default: return "ème" default: return "ème"
} }
} }
func ordinalFormatted() -> String { func ordinalFormatted(feminine: Bool = false) -> String {
return self.formatted() + self.ordinalFormattedSuffix() return self.formatted() + self.ordinalFormattedSuffix(feminine: feminine)
} }
var pluralSuffix: String { var pluralSuffix: String {

@ -10,9 +10,14 @@ import Foundation
// MARK: - Trimming and stuff // MARK: - Trimming and stuff
extension String { extension String {
func trunc(length: Int, trailing: String = "") -> String { func trunc(length: Int, trailing: String = "") -> String {
if length <= 0 { return self }
return (self.count > length) ? self.prefix(length) + trailing : self return (self.count > length) ? self.prefix(length) + trailing : self
} }
func prefixTrimmed(_ length: Int) -> String {
String(trimmed.prefix(length))
}
var trimmed: String { var trimmed: String {
replaceCharactersFromSet(characterSet: .newlines, replacementString: " ").trimmingCharacters(in: .whitespacesAndNewlines) replaceCharactersFromSet(characterSet: .newlines, replacementString: " ").trimmingCharacters(in: .whitespacesAndNewlines)
} }

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

@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import UIKit
enum DisplayContext { enum DisplayContext {
case addition case addition
@ -27,3 +28,40 @@ enum MatchViewStyle {
case plainStyle // vue detail case plainStyle // vue detail
case tournamentResultStyle //vue resultat tournoi 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 FederalTournamentAge.allCases.first(where: { $0.importingRawValue.canonicalVersion == ageCategory.canonicalVersion }) ?? .senior
} }
let resultOne = Array(dataOne.dropFirst(3).dropLast()) let resultOne = Array(dataOne.dropFirst(3).dropLast(3))
let resultTwo = Array(dataTwo.dropFirst(3).dropLast()) let resultTwo = Array(dataTwo.dropFirst(3).dropLast(3))
let sexUnknown: Bool = (resultOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (resultTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) 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 { var sexPlayerOne : Int {
switch tournamentCategory { switch tournamentCategory {
@ -434,39 +434,77 @@ class FileImportManager {
let fetchRequest = ImportedPlayer.fetchRequest() let fetchRequest = ImportedPlayer.fetchRequest()
let federalContext = PersistenceController.shared.localContainer.viewContext 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 var teamName: String? = nil
let players = team.map { player in let players = team.map { player in
let data = player.components(separatedBy: separator) let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.trimmed ?? "" let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.trimmed ?? "" let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
if data[safe: 1]?.trimmed != nil { if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed teamName = data[safe: 1]?.trimmed
} }
let phoneNumber : String? = data[safe: 4]?.trimmed let phoneNumber : String? = data[safe: 4]?.trimmed.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).prefixTrimmed(50)
let email : String? = data[safe: 5]?.trimmed let email : String? = data[safe: 5]?.prefixTrimmed(50)
let rank : Int? = data[safe: 6]?.trimmed.toInt() let rank : Int? = data[safe: 6]?.trimmed.toInt()
let licenceId : String? = data[safe: 7]?.trimmed let licenceId : String? = data[safe: 7]?.prefixTrimmed(50)
let club : String? = data[safe: 8]?.trimmed let club : String? = data[safe: 8]?.prefixTrimmed(200)
let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName) let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName)
fetchRequest.predicate = predicate fetchRequest.predicate = predicate
let found = try? federalContext.fetch(fetchRequest).first let found = try? federalContext.fetch(fetchRequest).first
if let found, autoSearch { if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found) let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament) player.setComputedRank(in: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player return player
} else { } else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email) let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
if rank == nil, autoSearch { if rank == nil, autoSearch {
player.setComputedRank(in: tournament) player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
} }
return player 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 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() .day()
.dateSeparator(.dash)) .dateSeparator(.dash))
let name = tournament.tournamentLevel.localizedLabel() + "-" + tournament.tournamentCategory.importingRawValue let name = tournament.tournamentLevel.localizedLevelLabel() + "-" + tournament.tournamentCategory.importingRawValue
return pdfFolderURL.appendingPathComponent(stringDate + "-" + name + ".pdf") return pdfFolderURL.appendingPathComponent(stringDate + "-" + name + ".pdf")
} }

@ -16,7 +16,18 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published var postalCode: String? @Published var postalCode: String?
@Published var requestStarted: Bool = false @Published var requestStarted: Bool = false
@Published var userReadableCityOrZipcode: String = "" @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() { override init() {
super.init() super.init()
@ -49,7 +60,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("locationManager didFailWithError", error) print("locationManager didFailWithError", error)
requestStarted = false requestStarted = false
self.lastError = error self.lastError = LocationError.unknownError(error: error)
} }
func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) { func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) {

@ -30,7 +30,7 @@ protocol TournamentBuildHolder: Identifiable {
var category: TournamentCategory { get } var category: TournamentCategory { get }
var level: TournamentLevel { get } var level: TournamentLevel { get }
var age: FederalTournamentAge { get } var age: FederalTournamentAge { get }
func buildHolderTitle() -> String func buildHolderTitle(_ displayStyle: DisplayStyle) -> String
} }
struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable { struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable {
@ -43,36 +43,36 @@ struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable {
// var japFirstName: String? = nil // var japFirstName: String? = nil
// var japLastName: String? = nil // var japLastName: String? = nil
func buildHolderTitle() -> String { func buildHolderTitle(_ displayStyle: DisplayStyle) -> String {
computedLabel computedLabel(displayStyle)
} }
var identifier: String { var identifier: String {
level.localizedLabel()+":"+category.localizedLabel()+":"+age.localizedLabel() level.localizedLevelLabel()+":"+category.localizedLabel()+":"+age.localizedLabel()
} }
var computedLabel: String { func computedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if age == .senior { return localizedLabel() } if age == .senior { return localizedLabel(displayStyle) }
return localizedLabel() + " " + localizedAge return localizedLabel(displayStyle) + " " + localizedAge(displayStyle)
} }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
level.localizedLabel() + category.localizedLabel(.short) level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle)
} }
var localizedTitle: String { func localizedTitle(_ displayStyle: DisplayStyle = .wide) -> String {
level.localizedLabel() + " " + category.localizedLabel() level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle)
} }
var localizedAge: String { func localizedAge(_ displayStyle: DisplayStyle = .wide) -> String {
age.tournamentDescriptionLabel age.localizedLabel(displayStyle)
} }
} }
extension TournamentBuild { extension TournamentBuild {
init?(category: String, level: String, age: FederalTournamentAge = .senior) { 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 var c = category
if c.hasPrefix("ME") { if c.hasPrefix("ME") {
@ -209,9 +209,9 @@ enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable {
case .senior: case .senior:
return "Senior" return "Senior"
case .a45: case .a45:
return "+45 ans" return "45 ans"
case .a55: case .a55:
return "+55 ans" return "55 ans"
} }
} }
@ -276,6 +276,28 @@ enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable {
var tournamentDescriptionLabel: String { var tournamentDescriptionLabel: String {
return localizedLabel() 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 { enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable {
@ -293,6 +315,16 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable {
self.init(rawValue: value) self.init(rawValue: value)
} }
var entryFee: Double? {
switch self {
case .unlisted:
return nil
case .p25:
return 15
default:
return 20
}
}
func searchRawValue() -> String { func searchRawValue() -> String {
String(describing: self) String(describing: self)
} }
@ -465,8 +497,14 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable {
} }
} }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { func localizedLevelLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if self == .unlisted { return displayStyle == .title ? "Animation" : "Anim." } if self == .unlisted {
if DeviceHelper.isBigScreen() {
return "Animation"
} else {
return displayStyle == .title ? "Animation" : "Anim."
}
}
return String(describing: self).capitalized return String(describing: self).capitalized
} }
@ -837,7 +875,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable {
case .men: case .men:
switch displayStyle { switch displayStyle {
case .title: case .title:
return "DH" return "Hommes"
case .wide: case .wide:
return "Hommes" return "Hommes"
case .short: case .short:
@ -846,7 +884,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable {
case .women: case .women:
switch displayStyle { switch displayStyle {
case .title: case .title:
return "DD" return "Dames"
case .wide: case .wide:
return "Dames" return "Dames"
case .short: case .short:
@ -855,7 +893,7 @@ enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable {
case .mix: case .mix:
switch displayStyle { switch displayStyle {
case .title: case .title:
return "MX" return "Mixte"
case .wide: case .wide:
return "Mixte" return "Mixte"
case .short: 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") Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte")
//todo //todo
//Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus") //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 { 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 { struct TipStyleModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var tint: Color? var tint: Color?

@ -7,6 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import TipKit
enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var id: Int { self.rawValue } 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 { func selectionLabel(index: Int) -> String {
localizedTitleKey localizedTitleKey
} }

@ -25,7 +25,7 @@ class FederalDataViewModel {
func filterStatus() -> String { func filterStatus() -> String {
var labels: [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: categories.map { $0.localizedLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() }.formatList()) labels.append(contentsOf: ageCategories.map { $0.localizedLabel() }.formatList())
let clubNames = selectedClubs.compactMap { codeClub in 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 { func isTournamentValidForFilters(_ tournament: Tournament) -> Bool {
if tournament.isDeleted { return false } if tournament.isDeleted { return false }
let firstPart = (levels.isEmpty || levels.contains(tournament.level)) 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: "license contains[cd] %@", canonicalVersionWithoutPunctuation))
} }
predicates.append(NSPredicate(format: "canonicalFullName 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 pattern = components.joined(separator: ".*")
let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern) let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
predicates.append(predicate) 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) }) 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: ".*") let pattern = components.joined(separator: ".*")
print(text, pattern) print(text, pattern)
let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern) let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)

@ -7,6 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import TipKit
protocol Selectable { protocol Selectable {
func selectionLabel(index: Int) -> String func selectionLabel(index: Int) -> String
@ -15,9 +16,14 @@ protocol Selectable {
func badgeValueColor() -> Color? func badgeValueColor() -> Color?
func displayImageIfValueZero() -> Bool func displayImageIfValueZero() -> Bool
func systemImage() -> String? func systemImage() -> String?
func associatedTip() -> (any Tip)?
} }
extension Selectable { extension Selectable {
func associatedTip() -> (any Tip)? {
return nil
}
func systemImage() -> String? { func systemImage() -> String? {
return nil 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) .headerProminence(.increased)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(focusedField != nil)
.toolbar(content: {
if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
}
})
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Message de convocation") .navigationTitle("Message de convocation")
.toolbar { .toolbar {

@ -124,7 +124,7 @@ struct MenuWarningView: View {
@ViewBuilder @ViewBuilder
func _teamActionView(_ team: TeamRegistration) -> some View { func _teamActionView(_ team: TeamRegistration) -> some View {
Menu("Toute l'équipe") { Menu(team.name ?? "Toute l'équipe") {
let players = team.players() let players = team.players()
_actionView(players: players) _actionView(players: players)
} }

@ -89,6 +89,7 @@ struct CashierDetailView: View {
let showTournamentTitle: Bool let showTournamentTitle: Bool
@State private var earnings: Double? = nil @State private var earnings: Double? = nil
@State private var paidCompletion: Double? = nil @State private var paidCompletion: Double? = nil
@State private var presence: Double? = nil
var body: some View { var body: some View {
Section { Section {
@ -99,11 +100,17 @@ struct CashierDetailView: View {
ProgressView() ProgressView()
} }
} label: { } label: {
Text("Encaissement") 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 { if let paidCompletion {
Text(paidCompletion.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) Text(paidCompletion.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary)
} }
} }
}
CashierDetailDisclosureView(tournament: tournament) CashierDetailDisclosureView(tournament: tournament)
} header: { } header: {
if showTournamentTitle { if showTournamentTitle {
@ -119,6 +126,10 @@ struct CashierDetailView: View {
if paidCompletion == nil { if paidCompletion == nil {
paidCompletion = tournament.paidCompletion() paidCompletion = tournament.paidCompletion()
} }
if presence == nil {
presence = tournament.presenceStatus()
}
} }
} }
} }

@ -11,36 +11,77 @@ import LeStorage
struct CashierSettingsView: View { struct CashierSettingsView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@State private var entryFee: Double? = nil
var tournaments: [Tournament] @Bindable var tournament: Tournament
@FocusState private var focusedField: Tournament.CodingKeys?
init(tournaments: [Tournament]) { let priceTags: [Double] = [15.0, 20.0, 25.0]
self.tournaments = tournaments
}
init(tournament: Tournament) { init(tournament: Tournament) {
self.tournaments = [tournament] self.tournament = tournament
_entryFee = State(wrappedValue: tournament.entryFee)
} }
var body: some View { var body: some View {
List { List {
Section { 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 { Section {
RowButtonView("Tout le monde est arrivé", role: .destructive) {
let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() }) let players = tournament.selectedPlayers() // tournaments.flatMap({ $0.selectedPlayers() })
players.forEach { player in players.forEach { player in
if player.hasPaid() == false { player.hasArrived = true
player.paymentType = .gift }
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 { do {
try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players) try tournament.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players)
} catch { } catch {
Logger.error(error) 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: { } footer: {
Text("Passe tous les joueurs qui n'ont pas réglé en offert") Text("Passe tous les joueurs qui n'ont pas réglé en offert")
@ -48,7 +89,6 @@ struct CashierSettingsView: View {
Section { Section {
RowButtonView("Personne n'a réglé", role: .destructive) { 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() let players = tournament.selectedPlayers()
@ -61,11 +101,60 @@ struct CashierSettingsView: View {
Logger.error(error) Logger.error(error)
} }
} }
}
} footer: { } footer: {
Text("Remet à zéro le type d'encaissement de tous les joueurs") 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() let id: UUID = UUID()
@Published var sortOption: SortOption = .callDate @Published var sortOption: SortOption = .callDate
@Published var filterOption: FilterOption = .all @Published var filterOption: FilterOption = .all
@Published var presenceFilterOption: PresenceFilterOption = .all
@Published var sortOrder: SortOrder = .ascending @Published var sortOrder: SortOrder = .ascending
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var isSearching: Bool = false @Published var isSearching: Bool = false
@ -69,9 +70,14 @@ class CashierViewModel: ObservableObject {
func _shouldDisplayPlayer(_ player: PlayerRegistration) -> Bool { func _shouldDisplayPlayer(_ player: PlayerRegistration) -> Bool {
if searchText.isEmpty == false { 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 { } 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 { struct CashierView: View {
@ -201,10 +238,35 @@ struct CashierView: View {
_players = .init(wrappedValue: teams.flatMap({ $0.unsortedPlayers() })) _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 { var body: some View {
List { List {
if cashierViewModel.isSearching == false { if cashierViewModel.isSearching == false {
Section { Section {
Picker(selection: $cashierViewModel.presenceFilterOption) {
ForEach(CashierViewModel.PresenceFilterOption.allCases) { filterOption in
Text(filterOption.localizedLabel()).tag(filterOption)
}
} label: {
Text("Présence")
}
if _isFree() == false {
Picker(selection: $cashierViewModel.filterOption) { Picker(selection: $cashierViewModel.filterOption) {
ForEach(CashierViewModel.FilterOption.allCases) { filterOption in ForEach(CashierViewModel.FilterOption.allCases) { filterOption in
Text(filterOption.localizedLabel()).tag(filterOption) Text(filterOption.localizedLabel()).tag(filterOption)
@ -212,6 +274,7 @@ struct CashierView: View {
} label: { } label: {
Text("Statut du règlement") Text("Statut du règlement")
} }
}
Picker(selection: $cashierViewModel.sortOption) { Picker(selection: $cashierViewModel.sortOption) {
ForEach(CashierViewModel.SortOption.allCases) { sortOption in ForEach(CashierViewModel.SortOption.allCases) { sortOption in
@ -239,12 +302,12 @@ struct CashierView: View {
switch cashierViewModel.sortOption { switch cashierViewModel.sortOption {
case .teamRank: case .teamRank:
TeamRankView(teams: teams, displayTournamentTitle: tournaments.count > 1) TeamRankView(teams: teams, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions())
case .alphabeticalLastName, .alphabeticalFirstName, .playerRank, .age: case .alphabeticalLastName, .alphabeticalFirstName, .playerRank, .age:
PlayerCashierView(players: filteredPlayers, displayTournamentTitle: tournaments.count > 1) PlayerCashierView(players: filteredPlayers, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions())
case .callDate: case .callDate:
let _teams = teams.filter({ $0.callDate != nil }) let _teams = teams.filter({ $0.callDate != nil })
TeamCallDateView(teams: _teams, displayTournamentTitle: tournaments.count > 1) TeamCallDateView(teams: _teams, displayTournamentTitle: tournaments.count > 1, editingOptions: _editingOptions())
} }
} }
.onAppear { .onAppear {
@ -279,11 +342,12 @@ struct CashierView: View {
@EnvironmentObject var cashierViewModel: CashierViewModel @EnvironmentObject var cashierViewModel: CashierViewModel
let players: [PlayerRegistration] let players: [PlayerRegistration]
let displayTournamentTitle: Bool let displayTournamentTitle: Bool
let editingOptions: [EditablePlayerView.PlayerEditingOption]
var body: some View { var body: some View {
ForEach(players) { player in ForEach(players) { player in
Section { Section {
EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) EditablePlayerView(player: player, editingOptions: editingOptions)
} header: { } header: {
if displayTournamentTitle, let tournamentTitle = player.tournament()?.tournamentTitle() { if displayTournamentTitle, let tournamentTitle = player.tournament()?.tournamentTitle() {
Text(tournamentTitle) Text(tournamentTitle)
@ -301,6 +365,7 @@ struct CashierView: View {
@EnvironmentObject var cashierViewModel: CashierViewModel @EnvironmentObject var cashierViewModel: CashierViewModel
let teams: [TeamRegistration] let teams: [TeamRegistration]
let displayTournamentTitle: Bool let displayTournamentTitle: Bool
let editingOptions: [EditablePlayerView.PlayerEditingOption]
var body: some View { var body: some View {
ForEach(teams) { team in ForEach(teams) { team in
@ -308,12 +373,18 @@ struct CashierView: View {
if players.isEmpty == false { if players.isEmpty == false {
Section { Section {
ForEach(players) { player in ForEach(players) { player in
EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) EditablePlayerView(player: player, editingOptions: editingOptions)
} }
} header: { } header: {
HStack {
if let name = team.name {
Text(name)
}
if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() { if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() {
Spacer()
Text(tournamentTitle) Text(tournamentTitle)
} }
}
} footer: { } footer: {
if let callDate = team.callDate { if let callDate = team.callDate {
Text("convocation : ") + Text(callDate.localizedDate()) Text("convocation : ") + Text(callDate.localizedDate())
@ -329,6 +400,7 @@ struct CashierView: View {
@EnvironmentObject var cashierViewModel: CashierViewModel @EnvironmentObject var cashierViewModel: CashierViewModel
let teams: [TeamRegistration] let teams: [TeamRegistration]
let displayTournamentTitle: Bool let displayTournamentTitle: Bool
let editingOptions: [EditablePlayerView.PlayerEditingOption]
var body: some View { var body: some View {
let groupedTeams = Dictionary(grouping: teams) { team in let groupedTeams = Dictionary(grouping: teams) { team in
@ -343,10 +415,15 @@ struct CashierView: View {
if players.isEmpty == false { if players.isEmpty == false {
Section { Section {
ForEach(players) { player in ForEach(players) { player in
EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) EditablePlayerView(player: player, editingOptions: editingOptions)
} }
} header: { } header: {
if let name = team.name {
Text(name)
}
if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() { if displayTournamentTitle, let tournamentTitle = team.tournamentObject()?.tournamentTitle() {
Spacer()
Text(tournamentTitle) Text(tournamentTitle)
} }
} footer: { } footer: {

@ -27,7 +27,7 @@ struct EventSettingsView: View {
link.append(tournaments.compactMap({ tournament in link.append(tournaments.compactMap({ tournament in
if let url = tournament.shareURL(pageLink) { if let url = tournament.shareURL(pageLink) {
var tournamentLink = [String]() var tournamentLink = [String]()
tournamentLink.append(tournament.tournamentTitle()) tournamentLink.append(tournament.tournamentTitle(.title))
tournamentLink.append(url.absoluteString) tournamentLink.append(url.absoluteString)
return tournamentLink.joined(separator: "\n") return tournamentLink.joined(separator: "\n")
} else { } else {
@ -46,12 +46,14 @@ struct EventSettingsView: View {
var body: some View { var body: some View {
Form { Form {
Section { Section {
TextField("Description de l'événement", text: $eventName, axis: .vertical) TextField("Nom de l'événement", text: $eventName, axis: .vertical)
.lineLimit(2) .lineLimit(2)
.keyboardType(.alphabet) .keyboardType(.alphabet)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.focused($textFieldIsFocus) .focused($textFieldIsFocus)
} header: {
Text("Nom de l'événement")
} footer: { } footer: {
if eventName.isEmpty == false { if eventName.isEmpty == false {
FooterButtonView("effacer le nom") { 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 { .toolbar {
if textFieldIsFocus { if textFieldIsFocus {
ToolbarItem(placement: .keyboard) { ToolbarItem(placement: .keyboard) {

@ -22,11 +22,12 @@ struct TournamentConfigurationView: View {
var body: some View { var body: some View {
Picker(selection: $tournament.federalLevelCategory, label: Text("Niveau")) { Picker(selection: $tournament.federalLevelCategory, label: Text("Niveau")) {
ForEach(TournamentLevel.allCases) { type in ForEach(TournamentLevel.allCases) { type in
Text(type.localizedLabel(.title)).tag(type) Text(type.localizedLevelLabel(.title)).tag(type)
} }
} }
.onChange(of: tournament.federalLevelCategory) { .onChange(of: tournament.federalLevelCategory) {
if tournament.federalLevelCategory == .unlisted { if tournament.federalLevelCategory == .unlisted {
tournament.hideTeamsWeight = true
tournament.federalCategory = .unlisted tournament.federalCategory = .unlisted
tournament.federalAgeCategory = .unlisted tournament.federalAgeCategory = .unlisted
} else { } else {
@ -40,7 +41,7 @@ struct TournamentConfigurationView: View {
} }
Picker(selection: $tournament.federalCategory, label: Text("Catégorie")) { Picker(selection: $tournament.federalCategory, label: Text("Catégorie")) {
ForEach(TournamentCategory.allCases) { type in 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")) { 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) .keyboardType(.alphabet)
.autocorrectionDisabled() .autocorrectionDisabled()
.defaultFocus($focusedField, ._name, priority: .automatic) .defaultFocus($focusedField, ._name, priority: .automatic)

@ -385,7 +385,7 @@ struct ClubSearchView: View {
LabeledContent { LabeledContent {
Text(club.distance(from: locationManager.location)) Text(club.distance(from: locationManager.location))
} label: { } label: {
Text(club.nom) Text(club.nom).lineLimit(1)
Text(club.ville).font(.caption) Text(club.ville).font(.caption)
} }
} }

@ -12,6 +12,7 @@ struct CourtView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@Bindable var court: Court @Bindable var court: Court
@State private var name: String = "" @State private var name: String = ""
@FocusState var focusedField: Court.CodingKeys?
init(court: Court) { init(court: Court) {
self.court = court self.court = court
@ -23,6 +24,7 @@ struct CourtView: View {
Section { Section {
LabeledContent { LabeledContent {
TextField("Nom", text: $name) TextField("Nom", text: $name)
.focused($focusedField, equals: ._name)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.alphabet) .keyboardType(.alphabet)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
@ -71,6 +73,16 @@ struct CourtView: View {
Logger.error(error) Logger.error(error)
} }
} }
.navigationBarBackButtonHidden(focusedField != nil)
.toolbar(content: {
if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
}
})
.navigationTitle(court.courtTitle()) .navigationTitle(court.courtTitle())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)

@ -11,13 +11,15 @@ fileprivate let defaultConfirmationMessage = "Êtes-vous sûr de vouloir faire c
struct FooterButtonView: View { struct FooterButtonView: View {
var role: ButtonRole? = nil var role: ButtonRole? = nil
var systemImage: String? = nil
let title: String let title: String
let confirmationMessage: String let confirmationMessage: String
let action: () -> () let action: () -> ()
@State private var askConfirmation: Bool = false @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.title = title
self.systemImage = systemImage
self.action = action self.action = action
self.role = role self.role = role
self.confirmationMessage = confirmationMessage ?? defaultConfirmationMessage self.confirmationMessage = confirmationMessage ?? defaultConfirmationMessage
@ -31,8 +33,16 @@ struct FooterButtonView: View {
action() action()
} }
} label: { } label: {
if let systemImage {
HStack {
Text(title) Text(title)
.underline() .underline()
Image(systemName: systemImage).font(.caption)
}
} else {
Text(title)
.underline()
}
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.confirmationDialog("Confirmation", .confirmationDialog("Confirmation",

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

@ -20,6 +20,7 @@ struct GroupStageSettingsView: View {
@State private var presentConfirmationButton: Bool = false @State private var presentConfirmationButton: Bool = false
@State private var size: Int @State private var size: Int
@State private var courtIndex: Int @State private var courtIndex: Int
@FocusState var focusedField: GroupStage.CodingKeys?
init(groupStage: GroupStage) { init(groupStage: GroupStage) {
_groupStage = Bindable(groupStage) _groupStage = Bindable(groupStage)
@ -37,6 +38,8 @@ struct GroupStageSettingsView: View {
Section { Section {
TextField("Nom de la poule", text: $groupStageName) TextField("Nom de la poule", text: $groupStageName)
.keyboardType(.alphabet) .keyboardType(.alphabet)
.focused($focusedField, equals: ._name)
.submitLabel(.done)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onSubmit { .onSubmit {
groupStageName = groupStageName.trimmed groupStageName = groupStageName.trimmed
@ -152,6 +155,16 @@ struct GroupStageSettingsView: View {
presentConfirmationButton = true presentConfirmationButton = true
} }
} }
.navigationBarBackButtonHidden(focusedField != nil)
.toolbar(content: {
if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
}
})
.navigationTitle("Paramètres") .navigationTitle("Paramètres")
.toolbarBackground(.visible, for: .navigationBar) .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 { var body: some View {
List { List {
Section { Section {
if let name = team.name {
Text(name).foregroundStyle(.secondary)
}
ForEach(team.players()) { player in ForEach(team.players()) { player in
EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) EditablePlayerView(player: player, editingOptions: _editingOptions())
.environmentObject(tournament.tournamentStore)
} }
} }

@ -57,12 +57,24 @@ struct GroupStageView: View {
MatchListView(section: "prêt à démarrer", matches: availableToStart, hideWhenEmpty: true) MatchListView(section: "prêt à démarrer", matches: availableToStart, hideWhenEmpty: true)
.listRowView(isActive: availableToStart.isEmpty == false, color: .green, hideColorVariation: true) .listRowView(isActive: availableToStart.isEmpty == false, color: .green, hideColorVariation: true)
MatchListView(section: "à lancer", matches: groupStage.readyMatches(playedMatches: playedMatches), hideWhenEmpty: 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 { if playedMatches.isEmpty {
RowButtonView("Créer les matchs de poules") { RowButtonView("Créer les matchs de poules") {
groupStage.buildMatches() 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 { .toolbar {
@ -70,7 +82,7 @@ struct GroupStageView: View {
_groupStageMenuView() _groupStageMenuView()
} }
} }
.navigationTitle(groupStage.groupStageTitle()) .navigationTitle(groupStage.groupStageTitle(.title))
} }
private enum GroupStageSortingMode { private enum GroupStageSortingMode {
@ -105,7 +117,7 @@ struct GroupStageView: View {
var body: some View { var body: some View {
ForEach(0..<(groupStage.size), id: \.self) { index in 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 { NavigationLink {
GroupStageTeamView(groupStage: groupStage, team: team) GroupStageTeamView(groupStage: groupStage, team: team)
.environment(self.tournament) .environment(self.tournament)
@ -125,8 +137,8 @@ struct GroupStageView: View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let teamName = team.name { if let teamName = team.name {
Text(teamName).foregroundStyle(.secondary) Text(teamName).font(.title3)
} } else {
ForEach(team.players()) { player in ForEach(team.players()) { player in
Text(player.playerLabel()).lineLimit(1) Text(player.playerLabel()).lineLimit(1)
.overlay { .overlay {
@ -136,8 +148,9 @@ struct GroupStageView: View {
} }
} }
} }
}
Spacer() 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) { VStack(alignment: .trailing) {
HStack(spacing: 0.0) { HStack(spacing: 0.0) {
Text(score.wins) Text(score.wins)

@ -10,9 +10,10 @@ import LeStorage
struct GroupStagesSettingsView: View { struct GroupStagesSettingsView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@Environment(\.dismiss) private var dismiss
@Environment(Tournament.self) var tournament: Tournament @Environment(Tournament.self) var tournament: Tournament
@State private var generationDone: Bool = false @State private var generationDone: Bool = false
let step: Int
var tournamentStore: TournamentStore { var tournamentStore: TournamentStore {
return self.tournament.tournamentStore return self.tournament.tournamentStore
@ -88,7 +89,6 @@ struct GroupStagesSettingsView: View {
} else if let groupStageLoserBracket = tournament.groupStageLoserBracket() { } else if let groupStageLoserBracket = tournament.groupStageLoserBracket() {
RowButtonView("Supprimer les matchs de classements", role: .destructive) { RowButtonView("Supprimer les matchs de classements", role: .destructive) {
do { do {
try groupStageLoserBracket.deleteDependencies()
try tournamentStore.rounds.delete(instance: groupStageLoserBracket) try tournamentStore.rounds.delete(instance: groupStageLoserBracket)
} catch { } catch {
Logger.error(error) 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 #if DEBUG
Section { Section {
RowButtonView("delete all group stages") { RowButtonView("delete all group stages") {

@ -12,6 +12,7 @@ struct GroupStagesView: View {
@State var tournament: Tournament @State var tournament: Tournament
@State private var selectedDestination: GroupStageDestination? @State private var selectedDestination: GroupStageDestination?
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
let step: Int
enum GroupStageDestination: Selectable, Identifiable, Equatable { enum GroupStageDestination: Selectable, Identifiable, Equatable {
static func == (lhs: GroupStagesView.GroupStageDestination, rhs: GroupStagesView.GroupStageDestination) -> Bool { static func == (lhs: GroupStagesView.GroupStageDestination, rhs: GroupStagesView.GroupStageDestination) -> Bool {
@ -77,17 +78,18 @@ struct GroupStagesView: View {
} }
var allMatches: [Match] { var allMatches: [Match] {
tournament.groupStagesMatches() tournament.groupStagesMatches(atStep: step)
} }
init(tournament: Tournament) { init(tournament: Tournament, step: Int = 0) {
self.tournament = tournament self.tournament = tournament
self.step = step
if tournament.shouldVerifyGroupStage { if tournament.shouldVerifyGroupStage {
_selectedDestination = State(wrappedValue: nil) _selectedDestination = State(wrappedValue: nil)
} else if tournament.unsortedTeams().filter({ $0.groupStagePosition != nil }).isEmpty { } else if tournament.unsortedTeams().filter({ $0.groupStagePosition != nil }).isEmpty {
_selectedDestination = State(wrappedValue: nil) _selectedDestination = State(wrappedValue: nil)
} else { } else {
let gs = tournament.getActiveGroupStage() let gs = tournament.getActiveGroupStage(atStep: step)
if let gs { if let gs {
_selectedDestination = State(wrappedValue: .groupStage(gs)) _selectedDestination = State(wrappedValue: .groupStage(gs))
} }
@ -96,7 +98,7 @@ struct GroupStagesView: View {
func allDestinations() -> [GroupStageDestination] { func allDestinations() -> [GroupStageDestination] {
var allDestinations : [GroupStageDestination] = [.all(tournament)] 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() { if let loserBracket = tournament.groupStageLoserBracket() {
allDestinations.insert(.loserBracket(loserBracket), at: 0) allDestinations.insert(.loserBracket(loserBracket), at: 0)
} }
@ -158,10 +160,11 @@ struct GroupStagesView: View {
case .loserBracket(let loserBracket): case .loserBracket(let loserBracket):
LoserBracketFromGroupStageView(loserBracket: loserBracket).id(loserBracket.id) LoserBracketFromGroupStageView(loserBracket: loserBracket).id(loserBracket.id)
case nil: case nil:
GroupStagesSettingsView() GroupStagesSettingsView(step: step)
.navigationTitle("Réglages") .navigationTitle("Réglages")
} }
} }
.environment(tournament)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
} }

@ -33,9 +33,13 @@ struct LoserBracketFromGroupStageView: View {
List { List {
if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false { if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false {
Section { Section {
RowButtonView("Ajouter un match", role: .destructive) { _addButton()
_addNewMatch()
} }
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 { ContentUnavailableView {
Label("Aucun match de classement", systemImage: "figure.tennis") Label("Aucun match de classement", systemImage: "figure.tennis")
} description: { } 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: { } actions: {
RowButtonView("Ajouter un match") { _addButton()
isEditingLoserBracketGroupStage = true _smartGenerationButton()
_addNewMatch()
}
} }
} }
} }
@ -117,15 +119,28 @@ struct LoserBracketFromGroupStageView: View {
let displayableMatches = loserBracket.playedMatches().sorted(by: \.index) let displayableMatches = loserBracket.playedMatches().sorted(by: \.index)
do { do {
for match in displayableMatches {
try match.deleteDependencies()
}
try tournamentStore.matches.delete(contentOfs: displayableMatches) try tournamentStore.matches.delete(contentOfs: displayableMatches)
} catch { } catch {
Logger.error(error) 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 { struct GroupStageLoserBracketMatchFooterView: View {
@ -155,7 +170,6 @@ struct GroupStageLoserBracketMatchFooterView: View {
Spacer() Spacer()
FooterButtonView("Effacer", role: .destructive) { FooterButtonView("Effacer", role: .destructive) {
do { do {
try match.deleteDependencies()
try match.tournamentStore.matches.delete(instance: match) try match.tournamentStore.matches.delete(instance: match)
} catch { } catch {
Logger.error(error) Logger.error(error)

@ -54,7 +54,7 @@ struct GroupStageTeamReplacementView: View {
Section { Section {
Picker(selection: $selectedPlayer) { Picker(selection: $selectedPlayer) {
HStack { HStack {
Text("Toute l'équipe") Text(team.name ?? "Toute l'équipe")
Spacer() Spacer()
Text(team.weight.formatted()).bold() Text(team.weight.formatted()).bold()
} }
@ -123,7 +123,7 @@ struct GroupStageTeamReplacementView: View {
private func _searchLinkView(_ teamRange: TeamRegistration.TeamRange) -> some View { private func _searchLinkView(_ teamRange: TeamRegistration.TeamRange) -> some View {
NavigationStack { NavigationStack {
let tournament = team.tournamentObject() 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) .foregroundStyle(Color.master)
.underline() .underline()
} else { } else {
Text("en attente") Text("démarrer")
.foregroundStyle(Color.master) .foregroundStyle(Color.master)
.underline() .underline()
} }

@ -32,13 +32,26 @@ struct MatchTeamDetailView: View {
private func _teamDetailView(_ team: TeamRegistration, inTournament tournament: Tournament?) -> some View { private func _teamDetailView(_ team: TeamRegistration, inTournament tournament: Tournament?) -> some View {
Section { Section {
ForEach(team.players()) { player in ForEach(team.players()) { player in
EditablePlayerView(player: player, editingOptions: [.licenceId, .name, .payment]) EditablePlayerView(player: player, editingOptions: _editingOptions())
} }
} header: { } header: {
TeamHeaderView(team: team, teamIndex: tournament?.indexOf(team: team)) 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 { //#Preview {

@ -57,20 +57,22 @@ struct PlayerBlockView: View {
} }
if let name = team?.name { if let name = team?.name {
Text(name).foregroundStyle(.secondary) Text(name).font(.title3)
} } else {
ForEach(names, id: \.self) { name in ForEach(names, id: \.self) { name in
Text(name).lineLimit(1) Text(name).lineLimit(1)
} }
}
} else { } else {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
VStack { VStack {
if let name = team?.name { if let name = team?.name {
Text(name).foregroundStyle(.secondary) Text(name).font(.title3)
} } else {
Text("longLabelPlayerOne").lineLimit(1) Text("longLabelPlayerOne").lineLimit(1)
Text("longLabelPlayerTwo").lineLimit(1) Text("longLabelPlayerTwo").lineLimit(1)
} }
}
.opacity(0) .opacity(0)
Text(_defaultLabel()).foregroundStyle(.secondary).lineLimit(1) Text(_defaultLabel()).foregroundStyle(.secondary).lineLimit(1)
} }

@ -105,8 +105,10 @@ struct MatchDetailView: View {
RowButtonView("Saisir les résultats", systemImage: "list.clipboard") { RowButtonView("Saisir les résultats", systemImage: "list.clipboard") {
self._editScores() self._editScores()
} }
.disabled(match.teams().count < 2)
} }
if self.match.currentTournament()?.isFree() == false {
let players = self.match.teams().flatMap { $0.players() } let players = self.match.teams().flatMap { $0.players() }
let unpaid = players.filter({ $0.hasPaid() == false }) let unpaid = players.filter({ $0.hasPaid() == false })
@ -130,7 +132,7 @@ struct MatchDetailView: View {
} }
} }
} }
}
menuView menuView
} }
.sheet(isPresented: $showDetails) { .sheet(isPresented: $showDetails) {
@ -423,9 +425,9 @@ struct MatchDetailView: View {
let rotationDuration = match.getDuration() let rotationDuration = match.getDuration()
Picker(selection: $startDateSetup) { Picker(selection: $startDateSetup) {
if match.isReady() { if match.isReady() {
Text("Tout de suite").tag(MatchDateSetup.now)
Text("Dans 5 minutes").tag(MatchDateSetup.inMinutes(5)) Text("Dans 5 minutes").tag(MatchDateSetup.inMinutes(5))
Text("Dans 15 minutes").tag(MatchDateSetup.inMinutes(15)) 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("Précédente rotation").tag(MatchDateSetup.inMinutes(-rotationDuration))
Text("Prochaine 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 parmi les libres").tag(MatchFieldSetup.random)
Text("Au hasard").tag(MatchFieldSetup.fullRandom) Text("Au hasard").tag(MatchFieldSetup.fullRandom)
//Text("Premier disponible").tag(MatchFieldSetup.firstAvailable) //Text("Premier disponible").tag(MatchFieldSetup.firstAvailable)
if let club = match.currentTournament()?.club() { if let tournament = match.currentTournament() {
ForEach(0..<club.courtCount, id: \.self) { courtIndex in
Text(club.courtName(atIndex: courtIndex)) .tag(MatchFieldSetup.field(courtIndex))
}
} else if let tournament = match.currentTournament() {
ForEach(0..<tournament.courtCount, id: \.self) { courtIndex in ForEach(0..<tournament.courtCount, id: \.self) { courtIndex in
Text(tournament.courtName(atIndex: courtIndex)) .tag(MatchFieldSetup.field(courtIndex)) Text(tournament.courtName(atIndex: courtIndex)) .tag(MatchFieldSetup.field(courtIndex))
} }

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

@ -23,9 +23,10 @@ struct ActivityView: View {
@State private var presentClubSearchView: Bool = false @State private var presentClubSearchView: Bool = false
@State private var quickAccessScreen: QuickAccessScreen? = nil @State private var quickAccessScreen: QuickAccessScreen? = nil
@State private var displaySearchView: Bool = false @State private var displaySearchView: Bool = false
@State private var pasteString: String? = nil
enum QuickAccessScreen : Identifiable, Hashable { enum QuickAccessScreen : Identifiable, Hashable {
case inscription(pasteString: String) case inscription
var id: String { var id: String {
switch self { switch self {
@ -75,13 +76,29 @@ struct ActivityView: View {
@ViewBuilder @ViewBuilder
private func _pasteView() -> some View { private func _pasteView() -> some View {
PasteButton(payloadType: String.self) { strings in Button {
guard let first = strings.first else { return } quickAccessScreen = .inscription
quickAccessScreen = .inscription(pasteString: first) } label: {
Image(systemName: "person.crop.circle.badge.plus")
.resizable()
.scaledToFit()
.frame(minHeight: 32)
} }
.foregroundStyle(.master) .accessibilityLabel("Ajouter une équipe")
.labelStyle(.iconOnly)
.buttonBorderShape(.capsule) // 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 { var body: some View {
@ -189,6 +206,10 @@ struct ActivityView: View {
.navigationDestination(for: Tournament.self) { tournament in .navigationDestination(for: Tournament.self) { tournament in
TournamentView(tournament: tournament) TournamentView(tournament: tournament)
} }
// .onDisappear(perform: {
// pasteButtonIsDisplayed = nil
// print("disappearing", "pasteButtonIsDisplayed", pasteButtonIsDisplayed)
// })
.toolbar { .toolbar {
ToolbarItemGroup(placement: .topBarLeading) { ToolbarItemGroup(placement: .topBarLeading) {
Button { Button {
@ -291,28 +312,41 @@ struct ActivityView: View {
} }
.sheet(item: $quickAccessScreen) { screen in .sheet(item: $quickAccessScreen) { screen in
switch screen { switch screen {
case .inscription(let pasteString): case .inscription:
NavigationStack { NavigationStack {
List { List {
if let pasteString {
Section { Section {
Text(pasteString) Text(pasteString)
.frame(maxWidth: .infinity)
.overlay {
if pasteString.isEmpty {
Text("Le presse-papier est vide")
.foregroundStyle(.secondary)
.italic()
}
}
} header: { } header: {
Text("Contenu du presse-papier") Text("Contenu du presse-papier")
} }
}
Section { Section {
ForEach(getRunningTournaments()) { tournament in ForEach(getRunningTournaments()) { tournament in
NavigationLink { NavigationLink {
AddTeamView(tournament: tournament, pasteString: pasteString, editedTeam: nil) AddTeamView(tournament: tournament, pasteString: pasteString, editedTeam: nil)
} label: { } label: {
VStack(alignment: .leading) { LabeledContent {
Text(tournament.unsortedTeamsWithoutWO().count.formatted())
} label: {
Text(tournament.tournamentTitle()) Text(tournament.tournamentTitle())
Text(tournament.formattedDate()).foregroundStyle(.secondary) Text(tournament.formattedDate())
} }
} }
} }
} header: { } header: {
Text("À coller dans la liste d'inscription") Text("Ajouter à la liste d'inscription")
} }
} }
.toolbar { .toolbar {
@ -321,6 +355,26 @@ struct ActivityView: View {
self.quickAccessScreen = nil 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") .navigationTitle("Choix du tournoi")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

@ -93,11 +93,11 @@ struct CalendarView: View {
if federalDataViewModel.isFederalTournamentValidForFilters(tournament, build: build) { if federalDataViewModel.isFederalTournamentValidForFilters(tournament, build: build) {
if navigation.agendaDestination == .around { if navigation.agendaDestination == .around {
NavigationLink(build.buildHolderTitle()) { NavigationLink(build.buildHolderTitle(.wide)) {
TournamentSubscriptionView(federalTournament: tournament, build: build, user: dataStore.user) TournamentSubscriptionView(federalTournament: tournament, build: build, user: dataStore.user)
} }
} else { } else {
Button(build.buildHolderTitle()) { Button(build.buildHolderTitle(.wide)) {
_createOrShow(federalTournament: tournament, existingTournament: event(forTournament: tournament)?.existingBuild(build), build: build) _createOrShow(federalTournament: tournament, existingTournament: event(forTournament: tournament)?.existingBuild(build), build: build)
} }
} }
@ -144,7 +144,9 @@ struct CalendarView: View {
let filteredTournaments = tournaments let filteredTournaments = tournaments
let mappedItems = filteredTournaments.flatMap { tournamentHolder in let mappedItems = filteredTournaments.flatMap { tournamentHolder in
(0..<tournamentHolder.dayDuration).map({ dayDuration 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: +) counts = Dictionary(mappedItems, uniquingKeysWith: +)

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

@ -23,15 +23,40 @@ struct TournamentLookUpView: View {
@State private var requestedToGetAllPages: Bool = false @State private var requestedToGetAllPages: Bool = false
@State private var revealSearchParameters: Bool = true @State private var revealSearchParameters: Bool = true
@State private var presentAlert: Bool = false @State private var presentAlert: Bool = false
@State private var confirmSearch: Bool = false
var tournaments: [FederalTournament] { var tournaments: [FederalTournament] {
federalDataViewModel.searchedFederalTournaments federalDataViewModel.searchedFederalTournaments
} }
var showLastError: Binding<Bool> {
Binding {
locationManager.lastError != nil
} set: { value in
}
}
var body: some View { var body: some View {
List { List {
searchParametersView 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: { .alert("Attention", isPresented: $presentAlert, actions: {
Button { Button {
presentAlert = false presentAlert = false
@ -70,8 +95,12 @@ struct TournamentLookUpView: View {
ToolbarItem(placement: .bottomBar) { ToolbarItem(placement: .bottomBar) {
if revealSearchParameters { if revealSearchParameters {
FooterButtonView("Lancer la recherche") { FooterButtonView("Lancer la recherche") {
if dataStore.appSettings.city.isEmpty {
confirmSearch = true
} else {
runSearch() runSearch()
} }
}
.disabled(searching) .disabled(searching)
} else if searching { } else if searching {
HStack(spacing: 20) { HStack(spacing: 20) {
@ -149,6 +178,9 @@ struct TournamentLookUpView: View {
federalDataViewModel.searchAttemptCount += 1 federalDataViewModel.searchAttemptCount += 1
federalDataViewModel.dayPeriod = dataStore.appSettings.dayPeriod federalDataViewModel.dayPeriod = dataStore.appSettings.dayPeriod
federalDataViewModel.dayDuration = dataStore.appSettings.dayDuration federalDataViewModel.dayDuration = dataStore.appSettings.dayDuration
federalDataViewModel.levels = Set(levels)
federalDataViewModel.categories = Set(categories)
federalDataViewModel.ageCategories = Set(ages)
Task { Task {
await getNewPage() await getNewPage()
@ -194,6 +226,12 @@ struct TournamentLookUpView: View {
let resultCommand = commands.first(where: { $0.results != nil }) let resultCommand = commands.first(where: { $0.results != nil })
if let newTournaments = resultCommand?.results?.items { if let newTournaments = resultCommand?.results?.items {
newTournaments.forEach { ft in 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 { if tournaments.contains(where: { $0.id == ft.id }) == false {
federalDataViewModel.searchedFederalTournaments.append(ft) 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 @ViewBuilder
var searchParametersView: some View { var searchParametersView: some View {
@Bindable var appSettings = dataStore.appSettings @Bindable var appSettings = dataStore.appSettings
@ -335,7 +348,7 @@ struct TournamentLookUpView: View {
NavigationLink { NavigationLink {
List([TournamentLevel.p25, TournamentLevel.p100, TournamentLevel.p250, TournamentLevel.p500, TournamentLevel.p1000, TournamentLevel.p1500, TournamentLevel.p2000], selection: $appSettings.tournamentLevels) { type in 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") .navigationTitle("Niveaux")
.environment(\.editMode, Binding.constant(EditMode.active)) .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 { if dataStore.appSettings.tournamentLevels.isEmpty || dataStore.appSettings.tournamentLevels.count == TournamentLevel.allCases.count {
Text("Tous les niveaux") Text("Tous les niveaux")
} else { } 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()) Text(federalTournament.clubLabel())
} }
LabeledContent("Épreuve") { LabeledContent("Épreuve") {
Text(build.buildHolderTitle()) Text(build.buildHolderTitle(.wide))
} }
LabeledContent("JAP") { LabeledContent("JAP") {
@ -292,24 +292,24 @@ struct TournamentSubscriptionView: View {
var messageBody: String { var messageBody: String {
let bonjourOuBonsoir = Date().timeOfDay.hello let bonjourOuBonsoir = Date().timeOfDay.hello
let bonneSoireeOuBonneJournee = Date().timeOfDay.goodbye 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 return body
} }
var messageBodyShort: String { var messageBodyShort: String {
let bonjourOuBonsoir = Date().timeOfDay.hello let bonjourOuBonsoir = Date().timeOfDay.hello
let bonneSoireeOuBonneJournee = Date().timeOfDay.goodbye 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 return body
} }
var noteCalendar: String { 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 return body
} }
var messageSubject: String { var messageSubject: String {
let subject = [build.buildHolderTitle(), federalTournament.clubLabel()].compacted().joined(separator: " ") let subject = [build.buildHolderTitle(.wide), federalTournament.clubLabel()].compacted().joined(separator: " ")
return subject return subject
} }

@ -17,7 +17,7 @@ struct RankCalculatorView: View {
Section { Section {
HStack { HStack {
let ordinal = NumberFormatter.ordinal.string(from: NSNumber(value:rank))! 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() Spacer()
Text(tournamentLevel.points(for: rank-1, count: count.rawValue).formatted(.number.sign(strategy: .always()))) Text(tournamentLevel.points(for: rank-1, count: count.rawValue).formatted(.number.sign(strategy: .always())))
} }
@ -25,7 +25,7 @@ struct RankCalculatorView: View {
Section { Section {
Picker(selection: $tournamentLevel) { Picker(selection: $tournamentLevel) {
ForEach(TournamentLevel.allCases) { level in ForEach(TournamentLevel.allCases) { level in
Text(level.localizedLabel()).tag(level) Text(level.localizedLevelLabel()).tag(level)
} }
} label: { } label: {
Label("Niveau", systemImage: "gauge.medium") Label("Niveau", systemImage: "gauge.medium")

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import LeStorage import LeStorage
import Zip
struct ToolboxView: View { struct ToolboxView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@ -128,7 +129,7 @@ struct ToolboxView: View {
Section { Section {
NavigationLink { NavigationLink {
SelectablePlayerListView() SelectablePlayerListView(isPresented: false)
} label: { } label: {
Label("Rechercher un joueur", systemImage: "person.fill.viewfinder") Label("Rechercher un joueur", systemImage: "person.fill.viewfinder")
} }
@ -210,13 +211,37 @@ struct ToolboxView: View {
} }
.navigationTitle(TabDestination.toolbox.title) .navigationTitle(TabDestination.toolbox.title)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarLeading) {
Link(destination: URLs.appStore.url) { Link(destination: URLs.appStore.url) {
Text("v\(PadelClubApp.appVersion)") 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
}
} }
} }

@ -27,7 +27,7 @@ struct GroupStageScheduleEditorView: View {
} }
var body: some 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 groupStage.startDate = startDate
tournament.matchScheduler()?.updateGroupStageSchedule(tournament: tournament, specificGroupStage: groupStage) tournament.matchScheduler()?.updateGroupStageSchedule(tournament: tournament, specificGroupStage: groupStage)
_save() _save()

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

@ -52,7 +52,7 @@ struct PlanningSettingsView: View {
Section { Section {
DatePicker(selection: $tournament.startDate) { DatePicker(selection: $tournament.startDate) {
Text(tournament.startDate.formatted(.dateTime.weekday(.wide)).capitalized) Text(tournament.startDate.formatted(.dateTime.weekday(.wide)).capitalized).lineLimit(1)
} }
LabeledContent { LabeledContent {
StepperView(count: $tournament.dayDuration, minimum: 1) StepperView(count: $tournament.dayDuration, minimum: 1)
@ -114,7 +114,7 @@ struct PlanningSettingsView: View {
} }
let allMatches = tournament.allMatches() let allMatches = tournament.allMatches()
let allGroupStages = tournament.groupStages() let allGroupStages = tournament.allGroupStages()
let allRounds = tournament.allRounds() let allRounds = tournament.allRounds()
let matchesWithDate = allMatches.filter({ $0.startDate != nil }) let matchesWithDate = allMatches.filter({ $0.startDate != nil })
let groupStagesWithDate = allGroupStages.filter({ $0.startDate != nil }) let groupStagesWithDate = allGroupStages.filter({ $0.startDate != nil })
@ -240,9 +240,9 @@ struct PlanningSettingsView: View {
let value = tournament.getGroupStageChunkValue() let value = tournament.getGroupStageChunkValue()
if parallelType == false { if parallelType == false {
if value > 1 { if value > 1 {
Text("\(value.formatted()) poules commenceront en parallèle") Text("\(value.formatted()) poules en parallèle")
} else { } 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 timeSlots: [Date:[Match]]
@State private var days: [Date] @State private var days: [Date]
@State private var keys: [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?>) { init(matches: [Match], selectedScheduleDestination: Binding<ScheduleDestination?>) {
self.matches = matches self.matches = matches
@ -30,6 +47,24 @@ struct PlanningView: View {
List { List {
_bySlotView() _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 { .overlay {
if matches.allSatisfy({ $0.startDate == nil }) { if matches.allSatisfy({ $0.startDate == nil }) {
ContentUnavailableView { ContentUnavailableView {
@ -53,7 +88,7 @@ struct PlanningView: View {
ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in
if let _matches = timeSlots[key] { if let _matches = timeSlots[key] {
DisclosureGroup { DisclosureGroup {
ForEach(_matches) { match in ForEach(_matches.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting)) { match in
NavigationLink { NavigationLink {
MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle)
} label: { } label: {
@ -63,7 +98,7 @@ struct PlanningView: View {
} }
} label: { } label: {
if let groupStage = match.groupStageObject { if let groupStage = match.groupStageObject {
Text(groupStage.groupStageTitle()) Text(groupStage.groupStageTitle(.title))
} else if let round = match.roundObject { } else if let round = match.roundObject {
Text(round.roundTitle()) Text(round.roundTitle())
} }
@ -98,7 +133,14 @@ struct PlanningView: View {
Text(self._formattedMatchCount(matches.count)) Text(self._formattedMatchCount(matches.count))
} label: { } label: {
Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) 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) { .onChange(of: tournament.groupStageMatchFormat) {
let groupStages = tournament.groupStages() let groupStages = tournament.allGroupStages()
groupStages.forEach { groupStage in groupStages.forEach { groupStage in
groupStage.updateMatchFormat(tournament.groupStageMatchFormat) groupStage.updateMatchFormat(tournament.groupStageMatchFormat)
} }
@ -68,7 +68,7 @@ struct SchedulerView: View {
} }
} }
ForEach(tournament.groupStages()) { ForEach(tournament.allGroupStages()) {
GroupStageScheduleEditorView(groupStage: $0, tournament: tournament) GroupStageScheduleEditorView(groupStage: $0, tournament: tournament)
.id(UUID()) .id(UUID())
} }

@ -14,6 +14,7 @@ struct EditablePlayerView: View {
case payment case payment
case licenceId case licenceId
case name case name
case presence
} }
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@ -77,6 +78,13 @@ struct EditablePlayerView: View {
Logger.error(error) Logger.error(error)
} }
} }
.onChange(of: player.hasArrived) {
do {
try self.tournamentStore.playerRegistrations.addOrUpdate(instance: player)
} catch {
Logger.error(error)
}
}
} }
@ViewBuilder @ViewBuilder
@ -91,11 +99,6 @@ struct EditablePlayerView: View {
Menu { Menu {
Button { Button {
player.hasArrived.toggle() player.hasArrived.toggle()
do {
try self.tournamentStore.playerRegistrations.addOrUpdate(instance: player)
} catch {
Logger.error(error)
}
} label: { } label: {
Label("Présent", systemImage: player.hasArrived ? "checkmark.circle" : "circle") Label("Présent", systemImage: player.hasArrived ? "checkmark.circle" : "circle")
} }
@ -172,6 +175,11 @@ struct EditablePlayerView: View {
if editingOptions.contains(.payment) { if editingOptions.contains(.payment) {
Spacer() Spacer()
PlayerPayView(player: player) 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? @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 { if let source {
let words = source.components(separatedBy: .whitespaces) let words = source.components(separatedBy: .whitespaces)
if words.isEmpty == false { if words.isEmpty == false {

@ -168,17 +168,22 @@ struct PlayerDetailView: View {
} }
} }
} }
.scrollDismissesKeyboard(.immediately)
.onChange(of: player.hasArrived) { .onChange(of: player.hasArrived) {
_save() _save()
} }
.onChange(of: player.sex) { .onChange(of: player.sex) {
_save() _save()
} }
.onChange(of: player.computedRank) { .navigationBarBackButtonHidden(focusedField != nil)
player.team()?.updateWeight(inTournamentCategory: tournament.tournamentCategory) .toolbar(content: {
_save() if focusedField != nil {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
focusedField = nil
}
}
} }
})
.headerProminence(.increased) .headerProminence(.increased)
.navigationTitle("Édition") .navigationTitle("Édition")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

@ -13,6 +13,14 @@ struct LoserRoundSettingsView: View {
@Environment(\.isEditingTournamentSeed) private var isEditingTournamentSeed @Environment(\.isEditingTournamentSeed) private var isEditingTournamentSeed
@Environment(Tournament.self) var tournament: Tournament @Environment(Tournament.self) var tournament: Tournament
@State var upperBracketRound: UpperRound @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 { var body: some View {
List { List {
@ -23,25 +31,31 @@ struct LoserRoundSettingsView: View {
} }
Section { Section {
@Bindable var round: Round = upperBracketRound.round Picker(selection: $loserBracketMode) {
Picker(selection: $round.loserBracketMode) {
ForEach(LoserBracketMode.allCases) { ForEach(LoserBracketMode.allCases) {
Text($0.localizedLoserBracketMode()).tag($0) Text($0.localizedLoserBracketMode()).tag($0)
} }
} label: { } label: {
Text("Position des perdants") Text("Position des perdants")
} }
.onChange(of: round.loserBracketMode) { .onChange(of: loserBracketMode) {
do { if upperBracketRound.round.allLoserRoundMatches().anySatisfy({ $0.hasEnded() }) == false {
try self.tournament.tournamentStore.rounds.addOrUpdate(instance: upperBracketRound.round) _refreshLoserBracketMode()
} catch { } else {
Logger.error(error) confirmationRequired = true
} }
} }
} header: { } header: {
Text("Matchs de classement") Text("Matchs de classement")
} footer: { } footer: {
if confirmationRequired == false {
Text(upperBracketRound.round.loserBracketMode.localizedLoserBracketModeDescription()) Text(upperBracketRound.round.loserBracketMode.localizedLoserBracketModeDescription())
} else {
_footerViewConfirmationRequired()
.onTapGesture(perform: {
presentConfirmation = true
})
}
} }
Section { Section {
@ -81,7 +95,58 @@ struct LoserRoundSettingsView: View {
//todo proposer ici l'impression des matchs de classements peut-être? //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 { //#Preview {

@ -259,7 +259,7 @@ struct RoundView: View {
#if DEBUG #if DEBUG
Spacer() Spacer()
Text(match.teamScores.count.formatted()) Text(match.index.formatted() + " " + match.teamScores.count.formatted())
#endif #endif
} }
} footer: { } 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) { .navigationDestination(isPresented: $showPrintScreen) {
PrintSettingsView(tournament: tournament) PrintSettingsView(tournament: tournament)
@ -327,9 +346,16 @@ struct RoundView: View {
match.name = Match.setServerTitle(upperRound: round, matchIndex: match.indexInRound(in: matches)) 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() let allRoundMatches = tournament.allRoundMatches()
do { do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches) try tournament.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }

@ -12,6 +12,9 @@ struct ImportedPlayerView: View {
var index: Int? = nil var index: Int? = nil
var showFemaleInMaleAssimilation: Bool = false var showFemaleInMaleAssimilation: Bool = false
var showProgression: Bool = false var showProgression: Bool = false
var isAnimation: Bool {
player.getComputedRank() == 0
}
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@ -39,6 +42,7 @@ struct ImportedPlayerView: View {
} }
.font(.title3) .font(.title3)
.lineLimit(1) .lineLimit(1)
if isAnimation == false {
HStack { HStack {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
Text(player.formattedRank()).italic(player.isAssimilated) Text(player.formattedRank()).italic(player.isAssimilated)
@ -78,6 +82,7 @@ struct ImportedPlayerView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -109,4 +114,5 @@ struct ImportedPlayerView: View {
} }
} }
} }
}
} }

@ -34,7 +34,7 @@ struct SelectablePlayerListView: View {
return URL.importDateFormatter.date(from: lastDataSource) 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.allowSelection = allowSelection
self.playerSelectionAction = playerSelectionAction self.playerSelectionAction = playerSelectionAction
self.contentUnavailableAction = contentUnavailableAction self.contentUnavailableAction = contentUnavailableAction
@ -45,7 +45,7 @@ struct SelectablePlayerListView: View {
searchViewModel.debouncableText = searchField ?? "" searchViewModel.debouncableText = searchField ?? ""
searchViewModel.showFemaleInMaleAssimilation = showFemaleInMaleAssimilation searchViewModel.showFemaleInMaleAssimilation = showFemaleInMaleAssimilation
searchViewModel.searchText = searchField ?? "" searchViewModel.searchText = searchField ?? ""
searchViewModel.isPresented = allowSelection != 0 searchViewModel.isPresented = isPresented
searchViewModel.allowSelection = allowSelection searchViewModel.allowSelection = allowSelection
searchViewModel.codeClub = fromPlayer?.clubCode ?? codeClub searchViewModel.codeClub = fromPlayer?.clubCode ?? codeClub
searchViewModel.clubName = nil searchViewModel.clubName = nil
@ -171,7 +171,8 @@ struct SelectablePlayerListView: View {
} }
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
.navigationBarBackButtonHidden(searchViewModel.allowMultipleSelection) .navigationBarBackButtonHidden(searchViewModel.allowMultipleSelection)
//.toolbarBackground(.visible, for: .bottomBar) .toolbarBackground(searchViewModel.allowMultipleSelection ? .visible : .hidden, for: .bottomBar)
.toolbarBackground(.visible, for: .navigationBar)
// .toolbarRole(searchViewModel.allowMultipleSelection ? .navigationStack : .editor) // .toolbarRole(searchViewModel.allowMultipleSelection ? .navigationStack : .editor)
.interactiveDismissDisabled(searchViewModel.selectedPlayers.isEmpty == false) .interactiveDismissDisabled(searchViewModel.selectedPlayers.isEmpty == false)
.navigationTitle(searchViewModel.label(forDataSet: searchViewModel.dataSet)) .navigationTitle(searchViewModel.label(forDataSet: searchViewModel.dataSet))
@ -221,7 +222,7 @@ struct SelectablePlayerListView: View {
if searchViewModel.selectedPlayers.isEmpty && searchViewModel.filterSelectionEnabled { if searchViewModel.selectedPlayers.isEmpty && searchViewModel.filterSelectionEnabled {
searchViewModel.filterSelectionEnabled = false searchViewModel.filterSelectionEnabled = false
} else { } else if searchViewModel.allowSelection >= searchViewModel.selectedPlayers.count {
searchViewModel.filterSelectionEnabled = true searchViewModel.filterSelectionEnabled = true
} }
} }
@ -247,7 +248,6 @@ struct SelectablePlayerListView: View {
} }
} }
if searchViewModel.selectedPlayers.isEmpty == false {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
ButtonValidateView { ButtonValidateView {
if let playerSelectionAction { if let playerSelectionAction {
@ -255,6 +255,7 @@ struct SelectablePlayerListView: View {
} }
dismiss() dismiss()
} }
.disabled(searchViewModel.selectedPlayers.isEmpty)
} }
ToolbarItem(placement: .status) { ToolbarItem(placement: .status) {
let count = searchViewModel.selectedPlayers.count let count = searchViewModel.selectedPlayers.count
@ -267,7 +268,6 @@ struct SelectablePlayerListView: View {
} }
} }
} }
}
// .modifierWithCondition(searchViewModel.user != nil) { thisView in // .modifierWithCondition(searchViewModel.user != nil) { thisView in
// thisView // thisView
.toolbarTitleMenu { .toolbarTitleMenu {
@ -430,6 +430,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -540,6 +541,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -654,6 +656,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -763,6 +766,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -874,6 +878,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -972,6 +977,7 @@ struct MySearchView: View {
} }
} }
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() { if showFemaleInMaleAssimilation, let assimilatedAsMaleRank = player.getAssimilatedAsMaleRank() {
HStack(alignment: .top, spacing: 2) { HStack(alignment: .top, spacing: 2) {
@ -1033,7 +1039,7 @@ struct MySearchView: View {
Text(searchViewModel.contentUnavailableMessage) Text(searchViewModel.contentUnavailableMessage)
} actions: { } actions: {
RowButtonView("Lancer une nouvelle recherche") { RowButtonView("Nouvelle recherche") {
searchViewModel.debouncableText = "" searchViewModel.debouncableText = ""
} }
.padding() .padding()

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

@ -45,7 +45,7 @@ struct TeamHeaderView: View {
let positionLabel = team.positionLabel() let positionLabel = team.positionLabel()
let cutLabel = tournament.cutLabel(index: teamIndex, teamCount: teamCount) let cutLabel = tournament.cutLabel(index: teamIndex, teamCount: teamCount)
if team.isWildCard() { if team.isWildCard() {
Text("wildcard").font(.caption).italic() Text("wildcard").foregroundStyle(.red).font(.caption).italic()
Text(positionLabel ?? cutLabel) Text(positionLabel ?? cutLabel)
} else { } else {
if let positionLabel { if let positionLabel {

@ -8,17 +8,16 @@
import SwiftUI import SwiftUI
struct TeamWeightView: View { struct TeamWeightView: View {
var team: TeamRegistration @EnvironmentObject var dataStore: DataStore
let team: TeamRegistration
var teamPosition: TeamPosition? = nil var teamPosition: TeamPosition? = nil
var teamIndex: Int?
var displayWeight: Bool = true
init(team: TeamRegistration, teamPosition: TeamPosition? = nil) { var teamIndex: Int? {
self.team = team team.tournamentObject()?.indexOf(team: team)
self.teamPosition = teamPosition }
let tournament = team.tournamentObject()
self.teamIndex = tournament?.indexOf(team: team) var displayWeight: Bool {
self.displayWeight = tournament?.hideWeight() == false team.tournamentObject()?.hideWeight() == false
} }
var body: some View { var body: some View {

@ -21,6 +21,7 @@ struct EditingTeamView: View {
@State private var registrationDate : Date @State private var registrationDate : Date
@State private var callDate : Date @State private var callDate : Date
@State private var name: String @State private var name: String
@FocusState private var focusedField: TeamRegistration.CodingKeys?
var messageSentFailed: Binding<Bool> { var messageSentFailed: Binding<Bool> {
Binding { Binding {
@ -137,9 +138,12 @@ struct EditingTeamView: View {
})) { })) {
Text("Forfait") Text("Forfait")
} }
}
Section {
TextField("Nom de l'équipe", text: $name) TextField("Nom de l'équipe", text: $name)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($focusedField, equals: ._name)
.keyboardType(.alphabet) .keyboardType(.alphabet)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.submitLabel(.done) .submitLabel(.done)
@ -153,6 +157,8 @@ struct EditingTeamView: View {
_save() _save()
} }
} header: {
Text("Nom de l'équipe")
} }
Section { 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) { .alert("Un problème est survenu", isPresented: messageSentFailed) {
Button("OK") { Button("OK") {
} }
@ -200,8 +216,6 @@ struct EditingTeamView: View {
case .failed: case .failed:
self.sentError = .messageFailed self.sentError = .messageFailed
case .sent: case .sent:
let uncalledTeams = team.getPhoneNumbers().isEmpty
if networkMonitor.connected == false { if networkMonitor.connected == false {
self.contactType = nil self.contactType = nil
if team.getPhoneNumbers().isEmpty == false { if team.getPhoneNumbers().isEmpty == false {
@ -232,8 +246,6 @@ struct EditingTeamView: View {
self.contactType = nil self.contactType = nil
self.sentError = .mailFailed self.sentError = .mailFailed
case .sent: case .sent:
let uncalledTeams = team.getMail().isEmpty
if networkMonitor.connected == false { if networkMonitor.connected == false {
self.contactType = nil self.contactType = nil
if team.getMail().isEmpty == false { if team.getMail().isEmpty == false {

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
struct TeamRowView: View { struct TeamRowView: View {
@EnvironmentObject var dataStore: DataStore
var team: TeamRegistration var team: TeamRegistration
var teamPosition: TeamPosition? = nil var teamPosition: TeamPosition? = nil
var displayCallDate: Bool = false var displayCallDate: Bool = false
@ -17,13 +18,10 @@ struct TeamRowView: View {
TeamWeightView(team: team, teamPosition: teamPosition) TeamWeightView(team: team, teamPosition: teamPosition)
} label: { } label: {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let name = team.name { HStack {
Text(name).foregroundStyle(.secondary)
}
if let groupStage = team.groupStageObject() { if let groupStage = team.groupStageObject() {
HStack { HStack {
Text(groupStage.groupStageTitle()) Text(groupStage.groupStageTitle(.title))
if let finalPosition = groupStage.finalPosition(ofTeam: team) { if let finalPosition = groupStage.finalPosition(ofTeam: team) {
Text((finalPosition + 1).ordinalFormatted()) Text((finalPosition + 1).ordinalFormatted())
} }
@ -32,6 +30,17 @@ struct TeamRowView: View {
Text(round.roundTitle(.wide)) Text(round.roundTitle(.wide))
} }
if team.isWildCard() {
Text("wildcard").italic().foregroundStyle(.red).font(.caption)
}
}
if let name = team.name {
Text(name).font(.title3)
if team.players().isEmpty {
Text("Aucun joueur")
}
} else {
if team.players().isEmpty == false { if team.players().isEmpty == false {
ForEach(team.players()) { player in ForEach(team.players()) { player in
Text(player.playerLabel()) Text(player.playerLabel())
@ -41,6 +50,7 @@ struct TeamRowView: View {
Text("Place réservée") Text("Place réservée")
} }
} }
}
if displayCallDate { if displayCallDate {
if let callDate = team.callDate { if let callDate = team.callDate {
Text("Déjà convoquée \(callDate.localizedDate())") Text("Déjà convoquée \(callDate.localizedDate())")

@ -85,11 +85,18 @@ struct FileImportView: View {
@State private var presentFormatHelperView: Bool = false @State private var presentFormatHelperView: Bool = false
@State private var validatedTournamentIds: Set<String> = Set() @State private var validatedTournamentIds: Set<String> = Set()
init(defaultFileProvider: FileImportManager.FileProvider = .frenchFederation) {
_fileProvider = .init(wrappedValue: defaultFileProvider)
}
var tournamentStore: TournamentStore { var tournamentStore: TournamentStore {
return self.tournament.tournamentStore return self.tournament.tournamentStore
} }
private func filteredTeams(tournament: Tournament) -> [FileImportManager.TeamHolder] { 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) return teams.filter { $0.tournamentCategory == tournament.tournamentCategory && $0.tournamentAgeCategory == tournament.federalTournamentAge }.sorted(by: \.weight)
} }
@ -309,10 +316,14 @@ struct FileImportView: View {
LabeledContent { LabeledContent {
Text(_filteredTeams.count.formatted()) Text(_filteredTeams.count.formatted())
} label: { } 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: { } 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) 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 { Section {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let teamName = team.name {
Text(teamName).foregroundStyle(.secondary)
}
ForEach(team.players.sorted(by: \.computedRank)) { ForEach(team.players.sorted(by: \.computedRank)) {
Text($0.playerLabel()) Text($0.playerLabel())
} }

@ -7,16 +7,15 @@
import SwiftUI import SwiftUI
import LeStorage import LeStorage
import CoreData
struct AddTeamView: View { struct AddTeamView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@FetchRequest( private var fetchRequest: FetchRequest<ImportedPlayer>
sortDescriptors: [], private var fetchPlayers: FetchedResults<ImportedPlayer> { fetchRequest.wrappedValue }
animation: .default)
private var fetchPlayers: FetchedResults<ImportedPlayer>
var tournament: Tournament var tournament: Tournament
var cancelShouldDismiss: Bool = false var cancelShouldDismiss: Bool = false
@ -41,6 +40,12 @@ struct AddTeamView: View {
@State private var homonyms: [PlayerRegistration] = [] @State private var homonyms: [PlayerRegistration] = []
@State private var confirmHomonym: Bool = false @State private var confirmHomonym: Bool = false
@State private var editableTextField: String = "" @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 { var tournamentStore: TournamentStore {
return self.tournament.tournamentStore return self.tournament.tournamentStore
@ -62,17 +67,23 @@ struct AddTeamView: View {
_createdPlayerIds = .init(wrappedValue: createdPlayerIds) _createdPlayerIds = .init(wrappedValue: createdPlayerIds)
} }
let request: NSFetchRequest<ImportedPlayer> = ImportedPlayer.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)]
request.fetchLimit = 1000
if let pasteString { if let pasteString {
_pasteString = .init(wrappedValue: 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) _autoSelect = .init(wrappedValue: true)
_editableTextField = .init(wrappedValue: pasteString) _editableTextField = .init(wrappedValue: pasteString)
_textHeight = .init(wrappedValue: Self._calculateHeight(text: pasteString))
cancelShouldDismiss = true cancelShouldDismiss = true
} }
fetchRequest = FetchRequest(fetchRequest: request, animation: .default)
} }
var body: some View { var body: some View {
if pasteString != nil, fetchPlayers.isEmpty == false { if let pasteString, pasteString.isEmpty == false, fetchPlayers.isEmpty == false {
computedBody computedBody
.searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher dans les résultats")) .searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher dans les résultats"))
} else { } else {
@ -84,14 +95,27 @@ struct AddTeamView: View {
List(selection: $createdPlayerIds) { List(selection: $createdPlayerIds) {
_buildingTeamView() _buildingTeamView()
} }
.onReceive(fetchPlayers.publisher.count()) { _ in // <-- here .onReceive(fetchPlayers.publisher.count()) { receivedCount in // <-- here
if let pasteString, count == 2, autoSelect == true { if let pasteString, pasteString.isEmpty == false, count == 2, autoSelect == true {
fetchPlayers.filter { $0.hitForSearch(pasteString) >= hitTarget }.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }).forEach { player in fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }).forEach { player in
createdPlayerIds.insert(player.license!) createdPlayerIds.insert(player.license!)
} }
autoSelect = false 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) { .alert("Présence d'homonyme", isPresented: $confirmHomonym) {
Button("Créer l'équipe quand même") { Button("Créer l'équipe quand même") {
_createTeam(checkDuplicates: false, checkHomonym: false) _createTeam(checkDuplicates: false, checkHomonym: false)
@ -117,28 +141,55 @@ struct AddTeamView: View {
} message: { } message: {
Text("Cette équipe existe déjà dans votre liste d'inscription.") Text("Cette équipe existe déjà dans votre liste d'inscription.")
} }
.sheet(isPresented: $presentPlayerSearch, onDismiss: { .sheet(isPresented: $presentLocalMultiplayerSearch) {
selectionSearchField = nil
}) {
NavigationStack { NavigationStack {
SelectablePlayerListView(allowSelection: -1, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in SelectablePlayerListView(allowSelection: -1, isPresented: false, searchField: searchField, dataSet: .club, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in
selectionSearchField = nil
players.forEach { player in players.forEach { player in
let newPlayer = PlayerRegistration(importedPlayer: player) let newPlayer = PlayerRegistration(importedPlayer: player)
newPlayer.setComputedRank(in: tournament) newPlayer.setComputedRank(in: tournament)
createdPlayers = Set<PlayerRegistration>()
createdPlayerIds = Set<String>()
createdPlayers.insert(newPlayer) createdPlayers.insert(newPlayer)
createdPlayerIds.insert(newPlayer.id) createdPlayerIds.insert(newPlayer.id)
_createTeam(checkDuplicates: false, checkHomonym: false)
} }
} contentUnavailableAction: { searchViewModel in } contentUnavailableAction: { searchViewModel in
presentLocalMultiplayerSearch = false
selectionSearchField = searchViewModel.searchText 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 presentPlayerSearch = false
presentPlayerCreation = true selectionSearchField = searchViewModel.searchText
} }
} }
.tint(.master) .tint(.master)
} }
.sheet(isPresented: $presentPlayerCreation) { .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) p.setComputedRank(in: tournament)
createdPlayers.insert(p) createdPlayers.insert(p)
createdPlayerIds.insert(p.id) createdPlayerIds.insert(p.id)
@ -155,7 +206,7 @@ struct AddTeamView: View {
if pasteString == nil { if pasteString == nil {
ToolbarItem(placement: .bottomBar) { ToolbarItem(placement: .bottomBar) {
PasteButton(payloadType: String.self) { strings in PasteButton(payloadType: String.self) { strings in
guard let first = strings.first else { return } let first = strings.first ?? ""
handlePasteString(first) handlePasteString(first)
} }
.foregroundStyle(.master) .foregroundStyle(.master)
@ -163,10 +214,30 @@ struct AddTeamView: View {
.buttonBorderShape(.capsule) .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) .navigationBarBackButtonHidden(true)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(.visible, for: .bottomBar) .toolbarBackground(.automatic, for: .bottomBar)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationTitle(editedTeam == nil ? "Ajouter une équipe" : "Modifier l'équipe") .navigationTitle(editedTeam == nil ? "Ajouter une équipe" : "Modifier l'équipe")
.environment(\.editMode, Binding.constant(EditMode.active)) .environment(\.editMode, Binding.constant(EditMode.active))
@ -192,10 +263,24 @@ 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 { Section {
RowButtonView("Créer un non classé / non licencié") { RowButtonView("Créer un non classé / non licencié") {
if let pasteString, pasteString.isEmpty == false {
selectionSearchField = pasteString
} else {
presentPlayerCreation = true presentPlayerCreation = true
} }
}
} footer: { } 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.") 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 return tournament.tournamentCategory.playerFilterOption
} }
private func _searchSource() -> String? {
selectionSearchField ?? pasteString
}
private func _currentSelection() -> Set<PlayerRegistration> { private func _currentSelection() -> Set<PlayerRegistration> {
var currentSelection = Set<PlayerRegistration>() var currentSelection = Set<PlayerRegistration>()
createdPlayerIds.compactMap { id in createdPlayerIds.compactMap { id in
@ -256,6 +337,7 @@ struct AddTeamView: View {
} }
private func _isDuplicate() -> Bool { private func _isDuplicate() -> Bool {
if tournament.isAnimation() { return false }
let ids : [String?] = _currentSelectionIds() let ids : [String?] = _currentSelectionIds()
if tournament.selectedSortedTeams().anySatisfy({ $0.containsExactlyPlayerLicenses(ids) }) { if tournament.selectedSortedTeams().anySatisfy({ $0.containsExactlyPlayerLicenses(ids) }) {
return true return true
@ -291,13 +373,15 @@ struct AddTeamView: View {
Logger.error(error) Logger.error(error)
} }
createdPlayers.removeAll()
createdPlayerIds.removeAll()
pasteString = nil pasteString = nil
editableTextField = "" editableTextField = ""
if team.players().count > 1 { if team.players().count > 1 {
createdPlayers.removeAll()
createdPlayerIds.removeAll()
dismiss() dismiss()
} else {
editedTeam = team
} }
} }
@ -320,23 +404,37 @@ struct AddTeamView: View {
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
createdPlayers.removeAll()
createdPlayerIds.removeAll()
pasteString = nil pasteString = nil
editableTextField = "" editableTextField = ""
self.editedTeam = nil
if editedTeam.players().count > 1 { if editedTeam.players().count > 1 {
dismiss() 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 @ViewBuilder
private func _buildingTeamView() -> some View { private func _buildingTeamView() -> some View {
if let pasteString { if let pasteString {
Section { Section {
TextEditor(text: $editableTextField) TextEditor(text: $editableTextField)
.frame(minHeight: 120, maxHeight: .infinity) .frame(height: textHeight)
.onChange(of: editableTextField) {
textHeight = Self._calculateHeight(text: pasteString)
}
.focused($focusedField, equals: .pasteField) .focused($focusedField, equals: .pasteField)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {
@ -346,8 +444,12 @@ struct AddTeamView: View {
} }
Spacer() Spacer()
Button("Chercher") { Button("Chercher") {
if editableTextField.count > 1 {
self.handlePasteString(editableTextField) self.handlePasteString(editableTextField)
self.focusedField = nil self.focusedField = nil
} else {
self.displayWarningNotEnoughCharacter = true
}
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
@ -360,12 +462,10 @@ struct AddTeamView: View {
self.focusedField = .pasteField self.focusedField = .pasteField
} }
Spacer() Spacer()
FooterButtonView("effacer", role: .destructive) { FooterButtonView("effacer le texte") {
self.focusedField = nil self.focusedField = nil
self.editableTextField = "" self.editableTextField = ""
self.pasteString = nil self.pasteString = nil
self.createdPlayers.removeAll()
self.createdPlayerIds.removeAll()
} }
} }
} }
@ -375,17 +475,30 @@ struct AddTeamView: View {
ForEach(createdPlayerIds.sorted(), id: \.self) { id in ForEach(createdPlayerIds.sorted(), id: \.self) { id in
if let p = createdPlayers.first(where: { $0.id == id }) { if let p = createdPlayers.first(where: { $0.id == id }) {
VStack(alignment: .leading, spacing: 0) { 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() 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) PlayerView(player: p).tag(p.id)
.environment(tournament)
} }
} }
if let p = fetchPlayers.first(where: { $0.license == id }) { if let p = fetchPlayers.first(where: { $0.license == id }) {
VStack(alignment: .leading, spacing: 0) { 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() 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!) ImportedPlayerView(player: p).tag(p.license!)
} }
} }
@ -403,7 +516,7 @@ struct AddTeamView: View {
} else { } else {
RowButtonView("Confirmer") { RowButtonView("Confirmer") {
_updateTeam(checkDuplicates: false) _updateTeam(checkDuplicates: false)
editedTeam = nil dismiss()
} }
} }
} header: { } header: {
@ -436,15 +549,21 @@ struct AddTeamView: View {
} }
if let pasteString { if let pasteString, pasteString.isEmpty == false {
if fetchPlayers.isEmpty { let sortedPlayers = _searchFilteredPlayers()
if sortedPlayers.isEmpty {
ContentUnavailableView { ContentUnavailableView {
Label("Aucun résultat", systemImage: "person.2.slash") Label("Aucun résultat", systemImage: "person.2.slash")
} description: { } 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.") 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: { } actions: {
RowButtonView("Créer un joueur non classé") { RowButtonView("Créer un joueur non classé") {
presentPlayerCreation = true selectionSearchField = pasteString
}
RowButtonView("Chercher dans la base") {
presentPlayerSearch = true
} }
RowButtonView("Effacer cette recherche") { RowButtonView("Effacer cette recherche") {
@ -454,20 +573,44 @@ struct AddTeamView: View {
} }
} else { } else {
_listOfPlayers(pasteString: pasteString) _listOfPlayers(searchFilteredPlayers: sortedPlayers, pasteString: pasteString)
} }
} else { } else {
_managementView() _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 { private var count: Int {
return fetchPlayers.filter { $0.hitForSearch(pasteString ?? "") >= hitTarget }.count return fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.count
} }
private var hitTarget: Int { private var hitTarget: Int {
if (pasteString?.matches(of: /[1-9][0-9]{5,7}/).count ?? 0) > 1 { 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 { } else {
return 2 return 2
} }
@ -482,31 +625,74 @@ struct AddTeamView: View {
} }
} }
@MainActor
private func handlePasteString(_ first: String) { private func handlePasteString(_ first: String) {
Task { if first.isEmpty == false {
await MainActor.run { DispatchQueue.main.async {
fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption()) fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption())
fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)] fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)]
pasteString = first
editableTextField = first
autoSelect = true autoSelect = true
} }
} }
pasteString = first
editableTextField = first
textHeight = Self._calculateHeight(text: first)
} }
@ViewBuilder @ViewBuilder
private func _listOfPlayers(pasteString: String) -> some View { private func _listOfPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> some View {
let sortedPlayers = fetchPlayers.filter({ $0.contains(searchField) || searchField.isEmpty }).sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }) let sortedPlayers = _sortedPlayers(searchFilteredPlayers: searchFilteredPlayers, pasteString: pasteString)
Section { Section {
ForEach(sortedPlayers) { player in ForEach(sortedPlayers) { player in
ImportedPlayerView(player: player).tag(player.license!) ImportedPlayerView(player: player).tag(player.license!)
//Text(player.getLastName() + " " + player.getFirstName()).tag(player.license!)
} }
} header: { } header: {
Text(sortedPlayers.count.formatted() + " résultat" + sortedPlayers.count.pluralSuffix) 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 navigation.selectedTab = .umpire
} }
RowButtonView("Jeter un oeil au site Padel Club") { RowButtonView("Voir le site Padel Club") {
UIApplication.shared.open(URLs.main.url) UIApplication.shared.open(URLs.main.url)
} }
} }
@ -104,12 +104,12 @@ struct BroadcastView: View {
Section { Section {
Toggle(isOn: $tournament.isPrivate) { Toggle(isOn: $tournament.isPrivate) {
Text("Tournoi privé") Text("Tournoi privé")
if (tournament.isPrivate && Guard.main.purchasedTransactions.isEmpty) {
Text("Vous devez disposer d'une offre pour rendre publique ce tournoi.")
.foregroundStyle(.logoRed)
} }
Toggle(isOn: $tournament.hideTeamsWeight) {
Text("Masquer les poids des équipes")
} }
.disabled(_disablePrivateToggle())
} footer: { } footer: {
let verb : String = tournament.isPrivate ? "est" : "sera" let verb : String = tournament.isPrivate ? "est" : "sera"
let footerString = " Le tournoi \(verb) masqué sur le site [Padel Club](\(URLs.main.rawValue))" 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") Text("Publication prévue")
} }
} }
Toggle(isOn: $tournament.hideTeamsWeight) {
Text("Masquer les poids des équipes")
}
} header: { } header: {
Text("Liste des équipes") Text("Liste des équipes")
} footer: { } footer: {
@ -273,6 +269,7 @@ struct BroadcastView: View {
} }
} }
.toolbar(content: { .toolbar(content: {
if StoreCenter.main.userId != nil, tournament.isPrivate == false, tournament.club() != nil {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
Section { Section {
@ -301,6 +298,7 @@ struct BroadcastView: View {
Label("Partager les liens", systemImage: "square.and.arrow.up") Label("Partager les liens", systemImage: "square.and.arrow.up")
} }
} }
}
}) })
.headerProminence(.increased) .headerProminence(.increased)
.navigationTitle("Publication") .navigationTitle("Publication")
@ -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() { private func _save() {
do { do {
if [tournament.publishTeams, tournament.publishSummons, tournament.publishBrackets, tournament.publishGroupStages].anySatisfy({ $0 == true }) { 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 duplicates : [PlayerRegistration] = []
@State private var problematicPlayers : [PlayerRegistration] = [] @State private var problematicPlayers : [PlayerRegistration] = []
@State private var inadequatePlayers : [PlayerRegistration] = [] @State private var inadequatePlayers : [PlayerRegistration] = []
@State private var ageInadequatePlayers : [PlayerRegistration] = []
@State private var playersWithoutValidLicense : [PlayerRegistration] = [] @State private var playersWithoutValidLicense : [PlayerRegistration] = []
@State private var entriesFromBeachPadel : [TeamRegistration] = [] @State private var entriesFromBeachPadel : [TeamRegistration] = []
@State private var playersMissing : [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.") 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 { Section {
DisclosureGroup { DisclosureGroup {
ForEach(playersWithoutValidLicense) { ForEach(playersWithoutValidLicense) {
@ -228,6 +246,7 @@ struct InscriptionInfoView: View {
homonyms = tournament.homonyms(in: players) homonyms = tournament.homonyms(in: players)
problematicPlayers = players.filter({ $0.sex == nil }) problematicPlayers = players.filter({ $0.sex == nil })
inadequatePlayers = tournament.inadequatePlayers(in: players) inadequatePlayers = tournament.inadequatePlayers(in: players)
ageInadequatePlayers = tournament.ageInadequatePlayers(in: players)
playersWithoutValidLicense = tournament.playersWithoutValidLicense(in: players) playersWithoutValidLicense = tournament.playersWithoutValidLicense(in: players)
entriesFromBeachPadel = tournament.unsortedTeams().filter({ $0.isImported() }) entriesFromBeachPadel = tournament.unsortedTeams().filter({ $0.isImported() })
playersMissing = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) playersMissing = selectedTeams.filter({ $0.unsortedPlayers().count < 2 })

@ -14,10 +14,15 @@ struct TournamentGeneralSettingsView: View {
@Bindable var tournament: Tournament @Bindable var tournament: Tournament
@State private var tournamentName: String = "" @State private var tournamentName: String = ""
@State private var entryFee: Double? = nil @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? @FocusState private var focusedField: Tournament.CodingKeys?
let priceTags: [Double] = [15.0, 20.0, 25.0]
init(tournament: Tournament) { init(tournament: Tournament) {
self.tournament = tournament self.tournament = tournament
_loserBracketMode = .init(wrappedValue: tournament.loserBracketMode)
_tournamentName = State(wrappedValue: tournament.name ?? "") _tournamentName = State(wrappedValue: tournament.name ?? "")
_entryFee = State(wrappedValue: tournament.entryFee) _entryFee = State(wrappedValue: tournament.entryFee)
} }
@ -25,9 +30,31 @@ struct TournamentGeneralSettingsView: View {
var body: some View { var body: some View {
@Bindable var tournament = tournament @Bindable var tournament = tournament
Form { 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 { Section {
TournamentDatePickerView() TournamentDatePickerView()
TournamentDurationManagerView() 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 { Section {
@ -35,31 +62,24 @@ struct TournamentGeneralSettingsView: View {
} }
Section { Section {
Picker(selection: $tournament.loserBracketMode) { Picker(selection: $loserBracketMode) {
ForEach(LoserBracketMode.allCases) { ForEach(LoserBracketMode.allCases) {
Text($0.localizedLoserBracketMode()).tag($0) Text($0.localizedLoserBracketMode()).tag($0)
} }
} label: { } label: {
Text("Position des perdants") Text("Position des perdants")
} }
.onChange(of: tournament.loserBracketMode) { .onChange(of: loserBracketMode) {
if tournament.allLoserRoundMatches().anySatisfy({ $0.hasEnded() }) == false {
_save() _refreshLoserBracketMode()
} else {
let rounds = tournament.rounds() confirmationRequired = true
rounds.forEach { round in
round.loserBracketMode = tournament.loserBracketMode
}
do {
try self.tournament.tournamentStore.rounds.addOrUpdate(contentOfs: rounds)
} catch {
Logger.error(error)
} }
} }
} header: { } header: {
Text("Matchs de classement") Text("Matchs de classement")
} footer: { } footer: {
if confirmationRequired == false {
if dataStore.user.loserBracketMode != tournament.loserBracketMode { if dataStore.user.loserBracketMode != tournament.loserBracketMode {
_footerView() _footerView()
.onTapGesture(perform: { .onTapGesture(perform: {
@ -69,33 +89,59 @@ struct TournamentGeneralSettingsView: View {
} else { } else {
Text(tournament.loserBracketMode.localizedLoserBracketModeDescription()) Text(tournament.loserBracketMode.localizedLoserBracketModeDescription())
} }
} else {
_footerViewConfirmationRequired()
.onTapGesture(perform: {
presentConfirmation = true
})
} }
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) .navigationBarBackButtonHidden(focusedField != nil)
.lineLimit(2) .toolbar(content: {
.frame(maxWidth: .infinity) if focusedField != nil {
.keyboardType(.alphabet) ToolbarItem(placement: .topBarLeading) {
.focused($focusedField, equals: ._name) Button("Annuler", role: .cancel) {
focusedField = nil
}
} }
} }
})
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.toolbar { .toolbar {
if focusedField != nil { if focusedField != nil {
ToolbarItem(placement: .keyboard) { ToolbarItem(placement: .keyboard) {
HStack { 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() Spacer()
Button("Valider") { Button("Valider") {
if focusedField == ._name { 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 { private func _footerView() -> some View {
Text(tournament.loserBracketMode.localizedLoserBracketModeDescription()) Text(tournament.loserBracketMode.localizedLoserBracketModeDescription())
+ +
Text(" Modifier le réglage par défaut pour tous vos tournois").foregroundStyle(.blue) 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")) { Picker(selection: $tournament.tournamentLevel, label: Text("Niveau")) {
ForEach(TournamentLevel.allCases) { type in ForEach(TournamentLevel.allCases) { type in
Text(type.localizedLabel(.title)).tag(type) Text(type.localizedLevelLabel(.title)).tag(type)
} }
} }
.onChange(of: tournament.federalLevelCategory) { .onChange(of: tournament.federalLevelCategory) {
if tournament.federalLevelCategory == .unlisted { if tournament.federalLevelCategory == .unlisted {
tournament.hideTeamsWeight = true
tournament.federalCategory = .unlisted tournament.federalCategory = .unlisted
tournament.federalAgeCategory = .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() _setHash()
}) { }) {
NavigationStack { NavigationStack {
FileImportView() FileImportView(defaultFileProvider: tournament.isAnimation() ? .custom : .frenchFederation)
} }
.tint(.master) .tint(.master)
} }
@ -308,6 +308,7 @@ struct InscriptionManagerView: View {
.symbolVariant(filterMode == .all ? .none : .fill) .symbolVariant(filterMode == .all ? .none : .fill)
} }
Menu { Menu {
if tournament.isAnimation() == false {
if tournament.inscriptionClosed() == false { if tournament.inscriptionClosed() == false {
Menu { Menu {
_sortingTypePickerView() _sortingTypePickerView()
@ -348,6 +349,21 @@ struct InscriptionManagerView: View {
Label("Ré-ouvrir", systemImage: "lock.open") Label("Ré-ouvrir", systemImage: "lock.open")
} }
} }
} else {
rankingDateSourcePickerView(showDateInLabel: true)
Divider()
_sharingTeamsMenuView()
Divider()
Button {
presentImportView = true
} label: {
Label("Importer un fichier", systemImage: "square.and.arrow.down")
}
}
} label: { } label: {
if tournament.inscriptionClosed() == false { if tournament.inscriptionClosed() == false {
LabelOptions() LabelOptions()
@ -438,9 +454,12 @@ struct InscriptionManagerView: View {
if presentSearch == false { if presentSearch == false {
_informationView() _informationView()
if tournament.isAnimation() == false {
_rankHandlerView() _rankHandlerView()
_relatedTips() _relatedTips()
} }
}
let teams = searchField.isEmpty ? filteredTeams : filteredTeams.filter({ $0.contains(searchField.canonicalVersion) }) let teams = searchField.isEmpty ? filteredTeams : filteredTeams.filter({ $0.contains(searchField.canonicalVersion) })
@ -650,7 +669,7 @@ struct InscriptionManagerView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
let registrationIssues = tournament.registrationIssues() let registrationIssues = tournament.registrationIssues()
if registrationIssues > 0 { if tournament.isAnimation() == false, registrationIssues > 0 {
NavigationLink { NavigationLink {
InscriptionInfoView() InscriptionInfoView()
.environment(tournament) .environment(tournament)
@ -660,7 +679,7 @@ struct InscriptionManagerView: View {
.foregroundStyle(.logoRed) .foregroundStyle(.logoRed)
.fontWeight(.bold) .fontWeight(.bold)
} label: { } 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 qualifiedPerGroupStage: Int = 0
@State private var groupStageAdditionalQualified: Int = 0 @State private var groupStageAdditionalQualified: Int = 0
@State private var updatedElements: Set<StructureElement> = Set() @State private var updatedElements: Set<StructureElement> = Set()
@State private var structurePreset: PadelTournamentStructurePreset = .manual
@FocusState private var stepperFieldIsFocused: Bool @FocusState private var stepperFieldIsFocused: Bool
var qualifiedFromGroupStage: Int { var qualifiedFromGroupStage: Int {
@ -51,6 +52,37 @@ struct TableStructureView: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
List { 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 { Section {
LabeledContent { LabeledContent {
StepperView(count: $teamCount, minimum: 4, maximum: 128) StepperView(count: $teamCount, minimum: 4, maximum: 128)
@ -62,6 +94,8 @@ struct TableStructureView: View {
} label: { } label: {
Text("Nombre de poules") 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 { if groupStageCount > 0 {
@ -73,17 +107,18 @@ struct TableStructureView: View {
Text("Équipes par poule") Text("Équipes par poule")
} }
if structurePreset == .manual {
LabeledContent { LabeledContent {
StepperView(count: $qualifiedPerGroupStage, minimum: 1, maximum: (teamsPerGroupStage-1)) StepperView(count: $qualifiedPerGroupStage, minimum: 0, maximum: (teamsPerGroupStage-1))
} label: { } label: {
Text("Qualifiés par poule") Text("Qualifié\(qualifiedPerGroupStage.pluralSuffix) par poule")
} }
if qualifiedPerGroupStage < teamsPerGroupStage - 1 { if qualifiedPerGroupStage < teamsPerGroupStage - 1 {
LabeledContent { LabeledContent {
StepperView(count: $groupStageAdditionalQualified, minimum: 0, maximum: maxMoreQualified) StepperView(count: $groupStageAdditionalQualified, minimum: 1, maximum: maxMoreQualified)
} label: { } label: {
Text("Qualifiés supplémentaires") Text("Qualifié\(groupStageAdditionalQualified.pluralSuffix) supplémentaires")
Text(moreQualifiedLabel) Text(moreQualifiedLabel)
} }
.onChange(of: groupStageAdditionalQualified) { .onChange(of: groupStageAdditionalQualified) {
@ -93,14 +128,42 @@ struct TableStructureView: View {
} }
} }
} }
}
if groupStageCount > 0 && teamsPerGroupStage > 0 { if groupStageCount > 0 && teamsPerGroupStage > 0 {
if structurePreset == .manual {
LabeledContent { LabeledContent {
let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2
Text(mp.formatted()) Text(mp.formatted())
} label: { } label: {
Text("Matchs à jouer par poule") 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")
}
}
} }
} }
} else { } else {
@ -111,17 +174,24 @@ struct TableStructureView: View {
Section { Section {
let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0) let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0)
if groupStageCount > 0 { if groupStageCount > 0 {
if structurePreset == .manual {
LabeledContent { LabeledContent {
Text(teamsFromGroupStages.formatted()) Text(teamsFromGroupStages.formatted())
} label: { } label: {
Text("Équipes en poule") Text("Équipes en poule")
} }
LabeledContent { LabeledContent {
Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted())
} label: { } label: {
Text("Équipes qualifiées de poule") Text("Équipes qualifiées de poule")
} }
} }
}
if structurePreset == .manual {
LabeledContent { LabeledContent {
let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0) let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0)
Text(tsPure.formatted()) Text(tsPure.formatted())
@ -133,6 +203,15 @@ struct TableStructureView: View {
} label: { } label: {
Text("Équipes en tableau final") 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")
}
}
} }
@ -154,6 +233,13 @@ struct TableStructureView: View {
_save(rebuildEverything: true) _save(rebuildEverything: true)
} }
} }
Section {
RowButtonView("Remise-à-zéro", role: .destructive) {
tournament.deleteGroupStages()
tournament.deleteStructure()
}
}
} }
} }
.focused($stepperFieldIsFocused) .focused($stepperFieldIsFocused)
@ -283,7 +369,7 @@ struct TableStructureView: View {
tournament.groupStageAdditionalQualified = groupStageAdditionalQualified tournament.groupStageAdditionalQualified = groupStageAdditionalQualified
if rebuildEverything { if rebuildEverything {
tournament.deleteAndBuildEverything() tournament.deleteAndBuildEverything(preset: structurePreset)
} else if (rebuildEverything == false && requirements.contains(.groupStage)) { } else if (rebuildEverything == false && requirements.contains(.groupStage)) {
tournament.deleteGroupStages() tournament.deleteGroupStages()
tournament.buildGroupStages() tournament.buildGroupStages()

@ -54,9 +54,15 @@ enum CashierDestination: Identifiable, Selectable, Equatable {
case .summary: case .summary:
return nil return nil
case .groupStage(let groupStage): 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 return groupStage.unsortedPlayers().filter({ $0.hasPaid() == false }).count
case .bracket(let round): case .bracket(let round):
let playerRegistrations: [PlayerRegistration] = round.seeds().flatMap { $0.unsortedPlayers() } 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 return playerRegistrations.filter({ $0.hasPaid() == false }).count
case .all(_): case .all(_):
return nil return nil
@ -156,7 +162,7 @@ struct TournamentCashierView: View {
.environmentObject(cashierViewModel) .environmentObject(cashierViewModel)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Encaissement") .navigationTitle(tournament.isFree() ? "Présence" : "Encaissement")
} }
} }

@ -51,7 +51,7 @@ struct TournamentRankView: View {
Logger.error(error) 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) { Toggle(isOn: $tournament.publishRankings) {
Text("Publier sur Padel Club") Text("Publier sur Padel Club")
if let url = tournament.shareURL(.rankings) { if let url = tournament.shareURL(.rankings) {
@ -250,8 +250,9 @@ struct TournamentRankView: View {
} }
} }
} }
if tournament.isAnimation() == false && key > 0 {
Spacer() Spacer()
if tournament.isAnimation() == false && key > 0 {
VStack(alignment: .trailing) { VStack(alignment: .trailing) {
HStack(alignment: .lastTextBaseline, spacing: 0.0) { HStack(alignment: .lastTextBaseline, spacing: 0.0) {
Text(tournament.tournamentLevel.points(for: key - 1, count: tournament.teamCount).formatted(.number.sign(strategy: .always()))) Text(tournament.tournamentLevel.points(for: key - 1, count: tournament.teamCount).formatted(.number.sign(strategy: .always())))
@ -310,27 +311,9 @@ struct TournamentRankView: View {
self.rankings.removeAll() self.rankings.removeAll()
let finalRanks = await tournament.finalRanking() let finalRanks = await tournament.finalRanking()
finalRanks.keys.sorted().forEach { rank in self.rankings = await tournament.setRankings(finalRanks: finalRanks)
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 calculating = false
} }
}
private func _save() { private func _save() {
do { do {

@ -23,7 +23,9 @@ struct TournamentCellView: View {
var body: some View { var body: some View {
ForEach(tournament.tournaments, id: \.id) { build in ForEach(tournament.tournaments, id: \.id) { build in
if navigation.agendaDestination == .around, let federalTournament = tournament as? FederalTournament { if let federalTournament = tournament as? FederalTournament {
if FederalDataViewModel.shared.isFederalTournamentValidForFilters(federalTournament, build: build) {
if navigation.agendaDestination == .around {
NavigationLink { NavigationLink {
TournamentSubscriptionView(federalTournament: federalTournament, build: build, user: dataStore.user) TournamentSubscriptionView(federalTournament: federalTournament, build: build, user: dataStore.user)
} label: { } label: {
@ -33,6 +35,10 @@ struct TournamentCellView: View {
_buildView(build, existingTournament: event?.existingBuild(build)) _buildView(build, existingTournament: event?.existingBuild(build))
} }
} }
} else {
_buildView(build, existingTournament: event?.existingBuild(build))
}
}
} }
var teamCount: Int? { var teamCount: Int? {
@ -71,7 +77,9 @@ struct TournamentCellView: View {
VStack(alignment: .leading, spacing: 0.0) { VStack(alignment: .leading, spacing: 0.0) {
if let tournament = tournament as? Tournament { if let tournament = tournament as? Tournament {
HStack { HStack {
Text(tournament.locationLabel(displayStyle)).lineLimit(1) Text(tournament.locationLabel(displayStyle))
.lineLimit(1)
.truncationMode(.tail)
.font(.caption) .font(.caption)
Spacer() Spacer()
if tournament.isPrivate { if tournament.isPrivate {
@ -88,9 +96,11 @@ struct TournamentCellView: View {
.font(.caption) .font(.caption)
} }
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
Text(build.level.localizedLabel()) Text(tournament.tournamentTitle(displayStyle, forBuild: build))
.fontWeight(.semibold) .fontWeight(.semibold)
if displayStyle == .wide { .lineLimit(1)
.truncationMode(.tail)
if displayStyle == .wide, tournament.displayAgeAndCategory(forBuild: build) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(build.category.localizedLabel()) Text(build.category.localizedLabel())
Text(build.age.localizedLabel()) Text(build.age.localizedLabel())
@ -122,8 +132,16 @@ struct TournamentCellView: View {
.font(displayStyle == .wide ? .title : .title3) .font(displayStyle == .wide ? .title : .title3)
if displayStyle == .wide { if displayStyle == .wide {
HStack { 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()) Text(tournament.durationLabel())
}
Spacer() Spacer()
if let tournament = tournament as? Tournament, tournament.isCanceled == false, let teamCount { if let tournament = tournament as? Tournament, tournament.isCanceled == false, let teamCount {
let hasStarted = tournament.inscriptionClosed() || tournament.hasStarted() let hasStarted = tournament.inscriptionClosed() || tournament.hasStarted()
@ -131,7 +149,6 @@ struct TournamentCellView: View {
Text(word + teamCount.pluralSuffix) Text(word + teamCount.pluralSuffix)
} }
} }
Text(tournament.subtitleLabel()).lineLimit(1)
} else { } else {
Text(build.category.localizedLabel()) Text(build.category.localizedLabel())
Text(build.age.localizedLabel()) Text(build.age.localizedLabel())

@ -30,7 +30,11 @@ struct TournamentBuildView: View {
ProgressView() ProgressView()
} }
} label: { } label: {
if tournament.groupStages(atStep: 1).isEmpty == false {
Text("1ère phase de poules")
} else {
Text("Poules") Text("Poules")
}
if tournament.groupStagesAreOver(), tournament.moreQualifiedToDraw() > 0 { if tournament.groupStagesAreOver(), tournament.moreQualifiedToDraw() > 0 {
let moreQualifiedToDraw = tournament.moreQualifiedToDraw() let moreQualifiedToDraw = tournament.moreQualifiedToDraw()
Text("Qualifié\(moreQualifiedToDraw.pluralSuffix) sortant\(moreQualifiedToDraw.pluralSuffix) manquant\(moreQualifiedToDraw.pluralSuffix)").foregroundStyle(.logoRed) 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 { if tournament.rounds().isEmpty == false {
NavigationLink(value: Screen.round) { NavigationLink(value: Screen.round) {
LabeledContent { LabeledContent {
@ -92,24 +113,6 @@ struct TournamentBuildView: View {
Section { 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() { if tournament.hasEnded() {
NavigationLink(value: Screen.rankings) { NavigationLink(value: Screen.rankings) {
LabeledContent { LabeledContent {
@ -128,7 +131,6 @@ struct TournamentBuildView: View {
} }
} }
} }
#endif
if state == .running || state == .finished { if state == .running || state == .finished {
TournamentInscriptionView(tournament: tournament) TournamentInscriptionView(tournament: tournament)
TournamentBroadcastRowView(tournament: tournament) TournamentBroadcastRowView(tournament: tournament)
@ -185,7 +187,7 @@ struct TournamentBuildView: View {
ProgressView() ProgressView()
} }
} label: { } label: {
Text("Encaissement") Text(tournament.isFree() ? "Présence" : "Encaissement")
if let tournamentStatus { if let tournamentStatus {
Text(tournamentStatus.label).lineLimit(1) Text(tournamentStatus.label).lineLimit(1)
} else { } else {

@ -141,7 +141,7 @@ struct TournamentView: View {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
VStack(spacing: -4.0) { VStack(spacing: -4.0) {
Text(tournament.tournamentTitle(.title)).font(.headline) Text(tournament.tournamentTitle(.title)).font(.headline)
Text(tournament.formattedDate()) Text(tournament.formattedDate(.title))
.font(.subheadline).foregroundStyle(.secondary) .font(.subheadline).foregroundStyle(.secondary)
} }
.popoverTip(tournamentSelectionTip) .popoverTip(tournamentSelectionTip)

@ -150,7 +150,7 @@ final class ServerDataTests: XCTestCase {
return 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) let gs: GroupStage = try await StoreCenter.main.service().post(groupStage)
assert(gs.tournament == groupStage.tournament) assert(gs.tournament == groupStage.tournament)
@ -159,6 +159,8 @@ final class ServerDataTests: XCTestCase {
assert(gs.size == groupStage.size) assert(gs.size == groupStage.size)
assert(gs.matchFormat == groupStage.matchFormat) assert(gs.matchFormat == groupStage.matchFormat)
assert(gs.startDate != nil) assert(gs.startDate != nil)
assert(gs.step == groupStage.step)
} }

Loading…
Cancel
Save