Laurent 1 year ago
commit edc99d1e79
  1. 1176
      PadelClub.xcodeproj/project.pbxproj
  2. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  3. 16
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  4. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  5. 4
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  6. 40
      PadelClub/Data/Federal/FederalTournament.swift
  7. 2
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  8. 5
      PadelClub/Data/GroupStage.swift
  9. 33
      PadelClub/Data/Match.swift
  10. 45
      PadelClub/Data/Round.swift
  11. 4
      PadelClub/Data/TeamRegistration.swift
  12. 74
      PadelClub/Data/Tournament.swift
  13. 7
      PadelClub/Data/User.swift
  14. 45
      PadelClub/Extensions/Array+Extensions.swift
  15. 20
      PadelClub/Extensions/Calendar+Extensions.swift
  16. 4
      PadelClub/Extensions/String+Extensions.swift
  17. 7
      PadelClub/Utils/ContactManager.swift
  18. 60
      PadelClub/Utils/Network/NetworkFederalService.swift
  19. 13
      PadelClub/Utils/PadelRule.swift
  20. 42
      PadelClub/ViewModel/AgendaDestination.swift
  21. 59
      PadelClub/ViewModel/FederalDataViewModel.swift
  22. 4
      PadelClub/ViewModel/SeedInterval.swift
  23. 5
      PadelClub/ViewModel/Selectable.swift
  24. 2
      PadelClub/Views/Calling/CallMessageCustomizationView.swift
  25. 2
      PadelClub/Views/Cashier/Event/EventCreationView.swift
  26. 2
      PadelClub/Views/Cashier/Event/EventSettingsView.swift
  27. 8
      PadelClub/Views/Components/GenericDestinationPickerView.swift
  28. 4
      PadelClub/Views/GroupStage/GroupStageView.swift
  29. 50
      PadelClub/Views/GroupStage/GroupStagesSettingsView.swift
  30. 21
      PadelClub/Views/GroupStage/GroupStagesView.swift
  31. 206
      PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift
  32. 85
      PadelClub/Views/GroupStage/LoserGroupStageSettingsView.swift
  33. 4
      PadelClub/Views/Match/Components/PlayerBlockView.swift
  34. 12
      PadelClub/Views/Match/EditSharingView.swift
  35. 3
      PadelClub/Views/Match/MatchRowView.swift
  36. 25
      PadelClub/Views/Match/MatchSetupView.swift
  37. 3
      PadelClub/Views/Match/MatchSummaryView.swift
  38. 162
      PadelClub/Views/Navigation/Agenda/ActivityView.swift
  39. 7
      PadelClub/Views/Navigation/Agenda/CalendarView.swift
  40. 40
      PadelClub/Views/Navigation/Agenda/EventListView.swift
  41. 483
      PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift
  42. 232
      PadelClub/Views/Navigation/Agenda/TournamentSubscriptionView.swift
  43. 27
      PadelClub/Views/Round/LoserRoundSettingsView.swift
  44. 8
      PadelClub/Views/Round/LoserRoundView.swift
  45. 4
      PadelClub/Views/Round/LoserRoundsView.swift
  46. 17
      PadelClub/Views/Round/RoundSettingsView.swift
  47. 2
      PadelClub/Views/Round/RoundView.swift
  48. 89
      PadelClub/Views/Shared/TournamentFilterView.swift
  49. 1
      PadelClub/Views/Team/EditingTeamView.swift
  50. 81
      PadelClub/Views/Team/TeamPickerView.swift
  51. 11
      PadelClub/Views/Team/TeamRowView.swift
  52. 14
      PadelClub/Views/Tournament/Screen/TournamentRankView.swift
  53. 10
      PadelClub/Views/Tournament/Shared/TournamentCellView.swift
  54. 7
      PadelClub/Views/Tournament/TournamentInitView.swift
  55. 3
      PadelClub/Views/Tournament/TournamentRunningView.swift
  56. 4
      PadelClub/Views/Tournament/TournamentView.swift

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF8044AE2C90379300A49A52"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
@ -44,7 +44,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF8044AE2C90379300A49A52"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
@ -57,6 +57,16 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -58,6 +58,10 @@ extension ImportedPlayer: PlayerHolder {
male
}
func pasteData() -> String {
return [firstName?.capitalized, lastName?.capitalized, license?.computedLicense].compactMap({ $0 }).joined(separator: " ")
}
func isNotFromCurrentDate() -> Bool {
if let importDate, importDate != SourceFileManager.shared.lastDataSourceDate() {
return true

@ -7,10 +7,23 @@ import Foundation
import CoreLocation
import LeStorage
enum DayPeriod {
enum DayPeriod: CaseIterable, Identifiable {
var id: Self { self }
case all
case weekend
case week
func localizedDayPeriodLabel() -> String {
switch self {
case .all:
return "n'importe"
case .week:
return "la semaine"
case .weekend:
return "le week-end"
}
}
}
// MARK: - FederalTournament
@ -126,10 +139,34 @@ struct FederalTournament: Identifiable, Codable {
?? []
}
var federalClub: FederalClub? {
if let codeClub {
return FederalClub(federalClubCode: codeClub, federalClubName: clubLabel())
} else {
return nil
}
}
var shareMessage: String {
[libelle, dateDebut?.formatted(date: .complete, time: .omitted)].compactMap({$0}).joined(separator: "\n") + "\n"
}
var japMessage: String {
[nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: ";")
}
func umpireLabel() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).joined(separator: " ")
}
func phoneLabel() -> String {
[installation?.telephone].compactMap({$0}).joined(separator: " ")
}
func mailLabel() -> String {
[courrielEngagement].compactMap({$0}).joined(separator: " ")
}
func validForSearch(_ searchText: String, scope: FederalTournamentSearchScope) -> Bool {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
@ -156,7 +193,6 @@ extension FederalTournament: FederalTournamentHolder {
// var importedId: Int { id }
var holderId: String { id.string }
func clubLabel() -> String {
nomClub ?? villeEngagement ?? installation?.nom ?? ""
}

@ -11,10 +11,12 @@ protocol FederalTournamentHolder {
var holderId: String { get }
var startDate: Date { get }
var endDate: Date? { get }
var codeClub: String? { get }
var tournaments: [any TournamentBuildHolder] { get }
func clubLabel() -> String
func subtitleLabel() -> String
var dayDuration: Int { get }
var dayPeriod: DayPeriod { get }
}
extension FederalTournamentHolder {

@ -384,6 +384,11 @@ final class GroupStage: ModelObject, Storable {
return data.joined(separator: "\n")
}
func finalPosition(ofTeam team: TeamRegistration) -> Int? {
guard hasEnded() else { return nil }
return teams(true).firstIndex(of: team)
}
override func deleteDependencies() throws {
let matches = self._matches()
for match in matches {

@ -121,6 +121,10 @@ defer {
print("func matchTitle", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if roundObject?.groupStageLoserBracket == true {
return "\(index)\(index.ordinalFormattedSuffix()) place"
}
if let groupStageObject {
return groupStageObject.localizedMatchUpLabel(for: index)
}
@ -361,9 +365,17 @@ defer {
return false
}
func _toggleMatchDisableState(_ state: Bool, forward: Bool = false) {
func _toggleMatchDisableState(_ state: Bool, forward: Bool = false, single: Bool = false) {
//if disabled == state { return }
disabled = state
if disabled {
do {
try self.tournamentStore.teamScores.delete(contentOfs: teamScores)
} catch {
Logger.error(error)
}
}
if state == true {
let teams = teams()
for team in teams {
@ -384,6 +396,7 @@ defer {
Logger.error(error)
}
if single == false {
_toggleLoserMatchDisableState(state)
if forward {
_toggleForwardMatchDisableState(state)
@ -392,6 +405,7 @@ defer {
bottomPreviousRoundMatch()?._toggleMatchDisableState(state)
}
}
}
func next() -> Match? {
let matches: [Match] = self.tournamentStore.matches.filter { $0.round == round && $0.index > index }
@ -837,7 +851,22 @@ defer {
}
var isLoserBracket: Bool {
return roundObject?.parent != nil
if let roundObject {
if roundObject.parent != nil || roundObject.groupStageLoserBracket {
return true
}
}
return false
}
var matchType: MatchType {
if isLoserBracket {
return .loserBracket
} else if isGroupStage() {
return .groupStage
} else {
return .bracket
}
}
enum CodingKeys: String, CodingKey {

@ -22,13 +22,15 @@ final class Round: ModelObject, Storable {
var parent: String?
private(set) var format: MatchFormat?
var startDate: Date?
var groupStageLoserBracket: Bool = false
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil) {
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil, groupStageLoserBracket: Bool = false) {
self.tournament = tournament
self.index = index
self.parent = parent
self.format = matchFormat
self.startDate = startDate
self.groupStageLoserBracket = groupStageLoserBracket
}
// MARK: - Computed dependencies
@ -67,7 +69,7 @@ final class Round: ModelObject, Storable {
}
func hasEnded() -> Bool {
if parent == nil {
if isUpperBracket() {
return playedMatches().anySatisfy({ $0.hasEnded() == false }) == false
} else {
return enabledMatches().anySatisfy({ $0.hasEnded() == false }) == false
@ -184,13 +186,13 @@ defer {
case .two:
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 + 1 }) {
return luckyLoser.team
} else if let previousMatch = bottomPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
} else if groupStageLoserBracket == false, let previousMatch = bottomPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
if let teamId = previousMatch.winningTeamId {
return self.tournamentStore.teamRegistrations.findById(teamId)
} else if previousMatch.disabled {
return previousMatch.teams().first
}
} else if let parent = upperBracketBottomMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
} else if groupStageLoserBracket == false, let parent = upperBracketBottomMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
return tournamentStore.findById(parent)
}
}
@ -292,7 +294,7 @@ defer {
// }
func playedMatches() -> [Match] {
if parent == nil {
if isUpperBracket() {
return enabledMatches()
} else {
return _matches()
@ -477,15 +479,19 @@ defer {
}
#endif
if parent == nil {
if isUpperBracket() {
if index == 0 { return SeedInterval(first: 1, last: 2) }
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
let seedsAfterThisRound : [TeamRegistration] = self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
}
let playedMatches = playedMatches()
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count)
let playedMatches = playedMatches().count
let minimumMatches = initialMode ? RoundRule.numberOfMatches(forRoundIndex: index) : playedMatches * 2
//print("playedMatches \(playedMatches)", initialMode, parent, parentRound?.roundTitle(), seedsAfterThisRound.count)
let seedInterval = SeedInterval(first: playedMatches + seedsAfterThisRound.count + 1, last: minimumMatches + seedsAfterThisRound.count)
//print(seedInterval.localizedLabel())
return seedInterval
}
@ -496,7 +502,7 @@ defer {
return previousRound.seedInterval(initialMode: initialMode)
}
} else if let parentRound {
if parentRound.parent == nil {
if parentRound.isUpperBracket() {
return parentRound.seedInterval(initialMode: initialMode)
}
return parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last
@ -506,6 +512,10 @@ defer {
}
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String {
if groupStageLoserBracket {
return "Classement Poules"
}
if parent != nil {
if let seedInterval = seedInterval(initialMode: initialMode) {
return seedInterval.localizedLabel(displayStyle)
@ -557,11 +567,11 @@ defer {
}
func isUpperBracket() -> Bool {
return parent == nil
return parent == nil && groupStageLoserBracket == false
}
func isLoserBracket() -> Bool {
return parent != nil
return parent != nil || groupStageLoserBracket
}
func deleteLoserBracket() {
@ -670,6 +680,18 @@ defer {
case _parent = "parent"
case _format = "format"
case _startDate = "startDate"
case _groupStageLoserBracket = "groupStageLoserBracket"
}
required 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)
parent = try container.decodeIfPresent(String.self, forKey: ._parent)
format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format)
startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate)
groupStageLoserBracket = try container.decodeIfPresent(Bool.self, forKey: ._groupStageLoserBracket) ?? false
}
func encode(to encoder: Encoder) throws {
@ -678,6 +700,7 @@ defer {
try container.encode(id, forKey: ._id)
try container.encode(tournament, forKey: ._tournament)
try container.encode(index, forKey: ._index)
try container.encode(groupStageLoserBracket, forKey: ._groupStageLoserBracket)
if let parent = parent {
try container.encode(parent, forKey: ._parent)

@ -503,6 +503,10 @@ final class TeamRegistration: ModelObject, Storable {
else { confirmationDate = nil }
}
func didConfirmSummon() -> Bool {
confirmationDate != nil
}
func tournamentObject() -> Tournament? {
return Store.main.findById(tournament)
}

@ -348,19 +348,19 @@ final class Tournament : ModelObject, Storable {
override func deleteDependencies() throws {
let store = self.tournamentStore
let teams = self.unsortedTeams()
let teams = self.tournamentStore.teamRegistrations
for team in teams {
try team.deleteDependencies()
}
store.teamRegistrations.deleteDependencies(teams)
let groups = self.groupStages()
let groups = self.tournamentStore.groupStages
for group in groups {
try group.deleteDependencies()
}
store.groupStages.deleteDependencies(groups)
let rounds = self.rounds()
let rounds = self.self.tournamentStore.rounds
for round in rounds {
try round.deleteDependencies()
}
@ -809,7 +809,7 @@ defer {
}
func rounds() -> [Round] {
let rounds: [Round] = self.tournamentStore.rounds.filter { $0.parent == nil }
let rounds: [Round] = self.tournamentStore.rounds.filter { $0.isUpperBracket() }
return rounds.sorted(by: \.index).reversed()
}
@ -1151,7 +1151,7 @@ defer {
return allMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false }).sorted(by: \.computedStartDateForSorting)
}
func finishedMatches(_ allMatches: [Match], limit: Int? = nil) -> [Match] {
func finishedMatches(_ allMatches: [Match], limit: Int?) -> [Match] {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
@ -1159,8 +1159,11 @@ defer {
print("func tournament finishedMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let _limit = limit ?? courtCount
return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed().prefix(_limit))
if let limit {
return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed().prefix(limit))
} else {
return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed())
}
}
func teamsRanked() -> [TeamRegistration] {
@ -1224,7 +1227,7 @@ defer {
_removeStrings(from: &teams, stringsToRemove: disabledIds)
teams[interval.last] = disabledIds
let teamNames : [String] = disabledIds.compactMap {
let t : TeamRegistration? = Store.main.findById($0)
let t : TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners.isEmpty", "\(interval.last) : ", teamNames)
@ -1237,7 +1240,7 @@ defer {
_removeStrings(from: &teams, stringsToRemove: winners)
teams[interval.first + winners.count - 1] = winners
let teamNames : [String] = winners.compactMap {
let t: TeamRegistration? = Store.main.findById($0)
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("winners", "\(interval.last + winners.count - 1) : ", teamNames)
@ -1246,25 +1249,35 @@ defer {
if losers.isEmpty == false {
_removeStrings(from: &teams, stringsToRemove: losers)
teams[interval.last] = losers
teams[interval.first + winners.count] = losers
let loserTeamNames : [String] = losers.compactMap {
let t: TeamRegistration? = Store.main.findById($0)
let t: TeamRegistration? = tournamentStore.teamRegistrations.findById($0)
return t
}.map { $0.canonicalName }
print("losers", "\(interval.last) : ", loserTeamNames)
print("losers", "\(interval.first + winners.count) : ", loserTeamNames)
losers.forEach { ids.insert($0) }
}
}
}
}
if let groupStageLoserBracketPlayedMatches = groupStageLoserBracket()?.playedMatches() {
groupStageLoserBracketPlayedMatches.forEach({ match in
if match.hasEnded() {
let sameMatchIndexCount = groupStageLoserBracketPlayedMatches.filter({ $0.index == match.index }).count
teams.setOrAppend(match.winningTeamId, at: match.index)
teams.setOrAppend(match.losingTeamId, at: match.index + sameMatchIndexCount)
}
})
}
let groupStages = groupStages()
let baseRank = teamCount - groupStageSpots() + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified
let alreadyPlaceTeams = Array(teams.values.flatMap({ $0 }))
groupStages.forEach { groupStage in
let groupStageTeams = groupStage.teams(true)
for (index, team) in groupStageTeams.enumerated() {
if team.qualified == false {
if team.qualified == false && alreadyPlaceTeams.contains(team.id) == false {
let groupStageWidth = max(((index == qualifiedPerGroupStage) ? groupStageCount - groupStageAdditionalQualified : groupStageCount) * (index - qualifiedPerGroupStage), 0)
let _index = baseRank + groupStageWidth + 1
@ -1934,10 +1947,7 @@ defer {
}
func tournamentWinner() -> TeamRegistration? {
let finals: Round? = self.tournamentStore.rounds.first(where: { $0.index == 0 && $0.parent == nil })
// let rounds: [Round] = Store.main.filter(isIncluded: { $0.index == 0 && $0.tournament == id && $0.parent == nil })
// let final: Round? = .first
let finals: Round? = self.tournamentStore.rounds.first(where: { $0.index == 0 && $0.isUpperBracket() })
return finals?.playedMatches().first?.winner()
}
@ -1998,19 +2008,27 @@ defer {
return (Array(shouldBeInIt), Array(shouldNotBeInIt))
}
func groupStageLoserBracket() -> Round? {
tournamentStore.rounds.first(where: { $0.groupStageLoserBracket })
}
func groupStageLoserBracketsInitialPlace() -> Int {
return teamCount - teamsPerGroupStage * groupStageCount + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified + 1
}
// MARK: -
func insertOnServer() throws {
DataStore.shared.tournaments.writeChangeAndInsertOnServer(instance: self)
for teamRegistration in self.unsortedTeams() {
for teamRegistration in self.tournamentStore.teamRegistrations {
teamRegistration.insertOnServer()
}
for groupStage in self.groupStages() {
for groupStage in self.tournamentStore.groupStages {
groupStage.insertOnServer()
}
for round in self.allRounds() {
for round in self.tournamentStore.rounds {
round.insertOnServer()
}
@ -2100,6 +2118,10 @@ extension Tournament: Hashable {
}
extension Tournament: FederalTournamentHolder {
var codeClub: String? {
club()?.code
}
var holderId: String { id }
func clubLabel() -> String {
@ -2115,6 +2137,16 @@ extension Tournament: FederalTournamentHolder {
self
]
}
var dayPeriod: DayPeriod {
let day = startDate.get(.weekday)
switch day {
case 2...6:
return .week
default:
return .weekend
}
}
}
extension Tournament: TournamentBuildHolder {

@ -75,6 +75,13 @@ class User: ModelObject, UserBase, Storable {
return "Sportivement,\n\(firstName) \(lastName), votre JAP."
}
func fullName() -> String? {
guard firstName.isEmpty == false && lastName.isEmpty == false else {
return nil
}
return "\(firstName) \(lastName)"
}
func hasTenupClubs() -> Bool {
self.clubsObjects().filter({ $0.code != nil }).isEmpty == false
}

@ -18,6 +18,16 @@ extension Array {
return first(where: { p($0) }) != nil
//return !self.allSatisfy { !p($0) }
}
// Check if the number of elements in the sequence is even
var isEven: Bool {
return self.count % 2 == 0
}
// Check if the number of elements in the sequence is odd
var isOdd: Bool {
return self.count % 2 != 0
}
}
extension Array where Element: Equatable {
@ -49,3 +59,38 @@ extension Array where Element: CustomStringConvertible {
}
}
extension Dictionary where Key == Int, Value == [String] {
mutating func setOrAppend(_ element: String?, at key: Int) {
// Check if the element is nil; do nothing if it is
guard let element = element else {
return
}
// Check if the key exists in the dictionary
if var array = self[key] {
// If it exists, append the element to the array
array.append(element)
self[key] = array
} else {
// If it doesn't exist, create a new array with the element
self[key] = [element]
}
}
}
extension Array where Element == String {
func formatList(maxDisplay: Int = 2) -> [String] {
// Check if the array has fewer or equal elements than the maximum display limit
if self.count <= maxDisplay {
// Join all elements with commas
return self
} else {
// Join only the first `maxDisplay` elements and add "et plus"
let displayedItems = self.prefix(maxDisplay)
let remainingCount = self.count - maxDisplay
return displayedItems.dropLast() + [displayedItems.last! + " et \(remainingCount) de plus"]
}
}
}

@ -49,3 +49,23 @@ extension Calendar {
return sportYear
}
}
extension Calendar {
// Add or subtract months from a date
func addMonths(_ months: Int, to date: Date) -> Date {
return self.date(byAdding: .month, value: months, to: date)!
}
// Generate a list of month start dates between two dates
func generateMonthRange(startDate: Date, endDate: Date) -> [Date] {
var dates: [Date] = []
var currentDate = startDate
while currentDate <= endDate {
dates.append(currentDate)
currentDate = self.addMonths(1, to: currentDate)
}
return dates
}
}

@ -17,6 +17,10 @@ extension String {
replaceCharactersFromSet(characterSet: .newlines).trimmingCharacters(in: .whitespacesAndNewlines)
}
var trimmedMultiline: String {
self.trimmingCharacters(in: .whitespacesAndNewlines)
}
func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String {
components(separatedBy: characterSet).joined(separator:replacementString)
}

@ -30,7 +30,10 @@ enum ContactType: Identifiable {
}
extension ContactType {
static let defaultCustomMessage: String = "Il est conseillé de vous présenter 10 minutes avant de jouer.\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire."
static let defaultCustomMessage: String =
"""
Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire.
"""
static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces."
static func callingCustomMessage(source: String? = nil, tournament: Tournament?, startDate: Date?, roundLabel: String) -> String {
@ -77,7 +80,7 @@ extension ContactType {
}
var computedMessage: String {
[entryFeeMessage, message].compacted().map { $0.trimmed }.joined(separator: "\n\n")
[entryFeeMessage, message].compacted().map { $0.trimmedMultiline }.joined(separator: "\n\n")
}
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes"

@ -194,4 +194,64 @@ recherche_type=club&club[autocomplete][value_container][value_field]=\(codeClub.
print("no data found in html")
}
}
func getAllFederalTournaments(sortingOption: String, page: Int, startDate: Date, endDate: Date, city: String, distance: Double, categories: [TournamentCategory], levels: [TournamentLevel], lat: String?, lng: String?, ages: [FederalTournamentAge], types: [FederalTournamentType], nationalCup: Bool) async throws -> [HttpCommand] {
var cityParameter = ""
var searchType = "ligue"
if city.trimmed.isEmpty == false {
searchType = "ville"
cityParameter = city
}
var levelsParameter = ""
if levels.isEmpty == false {
levelsParameter = levels.map { "categorie_tournoi[\($0.localizedLabel)]=\($0.localizedLabel)" }.joined(separator: "&") + "&"
}
var categoriesParameter = ""
if categories.isEmpty == false {
categoriesParameter = categories.map { "epreuve[\($0.requestLabel)]=\($0.requestLabel)" }.joined(separator: "&") + "&"
}
var agesParameter = ""
if ages.isEmpty == false {
agesParameter = ages.map { "categorie_age[\($0.rawValue)]=\($0.rawValue)" }.joined(separator: "&") + "&"
}
var typesParameter = ""
if types.isEmpty == false {
typesParameter = types.map { "type[\($0.rawValue.capitalized)]=\($0.rawValue.capitalized)" }.joined(separator: "&") + "&"
}
var npc = ""
if nationalCup {
npc = "&tournoi_npc=1"
}
let parameters = """
recherche_type=\(searchType)&ville%5Bautocomplete%5D%5Bcountry%5D=fr&ville%5Bautocomplete%5D%5Btextfield%5D=&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blat_field%5D=\(lat ?? "")&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blng_field%5D=\(lng ?? "")&ville%5Bdistance%5D%5Bvalue_field%5D=\(Int(distance))&club%5Bautocomplete%5D%5Btextfield%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=&pratique=PADEL&date%5Bstart%5D=\(startDate.twoDigitsYearFormatted)&date%5Bend%5D=\(endDate.twoDigitsYearFormatted)&\(categoriesParameter)\(levelsParameter)\(agesParameter)\(typesParameter)\(npc)&page=\(page)&sort=\(sortingOption)&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page
"""
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.httpMethod = "POST"
request.httpBody = postData
return try await runTenupTask(request: request)
}
}

@ -6,6 +6,7 @@
//
import Foundation
import LeStorage
enum RankSource: Hashable {
case national
@ -33,7 +34,8 @@ protocol TournamentBuildHolder: Identifiable {
}
struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable {
var id: String { identifier }
var uniqueId: String = Store.randomId()
var id: String { uniqueId }
let category: TournamentCategory
let level: TournamentLevel
let age: FederalTournamentAge
@ -42,7 +44,7 @@ struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable {
// var japLastName: String? = nil
func buildHolderTitle() -> String {
localizedLabel()
computedLabel
}
var identifier: String {
@ -291,6 +293,13 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable {
self.init(rawValue: value)
}
func pointsRange(first: Int, last: Int, teamsCount: Int) -> String {
let range = [points(for: first - 1, count: teamsCount),
points(for: last - 1, count: teamsCount)]
return range.map { $0.formatted(.number.sign(strategy: .always())) }.joined(separator: " / ") + " pts"
}
func hideWeight() -> Bool {
switch self {
case .unlisted:

@ -18,11 +18,7 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
case activity
case history
case tenup
enum ViewStyle {
case list
case calendar
}
case around
var localizedTitleKey: String {
switch self {
@ -32,6 +28,8 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
return "Terminé"
case .tenup:
return "Tenup"
case .around:
return "Autour"
}
}
@ -39,14 +37,12 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
localizedTitleKey
}
var systemImage: String {
func systemImage() -> String? {
switch self {
case .activity:
return "squares.leading.rectangle"
case .history:
return "book.closed"
case .tenup:
return "tennisball"
case .around:
return "location.magnifyingglass"
default:
return nil
}
}
@ -58,6 +54,9 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
case .tenup:
FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+)
case .around:
nil
}
}
@ -84,6 +83,25 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
} else {
return nil
}
case .around:
return nil
}
}
}
enum ViewStyle {
case list
case calendar
}
struct ViewStyleKey: EnvironmentKey {
static let defaultValue: ViewStyle = .list
}
extension EnvironmentValues {
var viewStyle: ViewStyle {
get { self[ViewStyleKey.self] }
set { self[ViewStyleKey.self] = newValue }
}
}

@ -13,26 +13,44 @@ class FederalDataViewModel {
static let shared = FederalDataViewModel()
var federalTournaments: [FederalTournament] = []
var searchedFederalTournaments: [FederalTournament] = []
var levels: Set<TournamentLevel> = Set()
var categories: Set<TournamentCategory> = Set()
var ageCategories: Set<FederalTournamentAge> = Set()
var selectedClubs: Set<String> = Set()
var id: UUID = UUID()
var searchAttemptCount: Int = 0
var dayDuration: Int?
var dayPeriod: DayPeriod = .all
func filterStatus() -> String {
var labels: [String] = []
labels.append(contentsOf: levels.map { $0.localizedLabel() })
labels.append(contentsOf: categories.map { $0.localizedLabel() })
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() })
labels.append(contentsOf: levels.map { $0.localizedLabel() }.formatList())
labels.append(contentsOf: categories.map { $0.localizedLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() }.formatList())
let clubNames = selectedClubs.compactMap { codeClub in
let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub })
return club?.clubTitle(.short)
}
labels.append(contentsOf: clubNames)
labels.append(contentsOf: clubNames.formatList())
if dayPeriod != .all {
labels.append(dayPeriod.localizedDayPeriodLabel())
}
if let dayDuration {
labels.append("max " + dayDuration.formatted() + " jour" + dayDuration.pluralSuffix)
}
return labels.joined(separator: ", ")
}
var searchedClubs: [FederalClub] {
searchedFederalTournaments.compactMap { ft in
ft.federalClub
}.uniqued { fc in
fc.federalClubCode
}.sorted(by: \.federalClubName)
}
func selectedClub() -> Club? {
if selectedClubs.isEmpty == false {
return DataStore.shared.clubs.first(where: { $0.code == selectedClubs.first! })
@ -46,15 +64,25 @@ class FederalDataViewModel {
categories.removeAll()
ageCategories.removeAll()
selectedClubs.removeAll()
dayPeriod = .all
dayDuration = nil
id = UUID()
}
func areFiltersEnabled() -> Bool {
(levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty) == false
(levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false
}
var filteredFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: federalTournaments)
}
var filteredSearchedFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: searchedFederalTournaments)
}
var filteredFederalTournaments: [FederalTournament] {
federalTournaments.filter({ tournament in
func filteredFederalTournaments(from tournaments: [any FederalTournamentHolder]) -> [FederalTournamentHolder] {
tournaments.filter({ tournament in
(levels.isEmpty || tournament.tournaments.anySatisfy({ levels.contains($0.level) }))
&&
(categories.isEmpty || tournament.tournaments.anySatisfy({ categories.contains($0.category) }))
@ -62,6 +90,10 @@ class FederalDataViewModel {
(ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) }))
&&
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
})
}
@ -72,6 +104,10 @@ class FederalDataViewModel {
(categories.isEmpty || categories.contains(tournament.category))
&&
(ageCategories.isEmpty || ageCategories.contains(tournament.age))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
if let codeClub = tournament.club()?.code {
return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub))
@ -88,6 +124,10 @@ class FederalDataViewModel {
(ageCategories.isEmpty || ageCategories.contains(build.age))
&&
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
}
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
@ -103,3 +143,8 @@ class FederalDataViewModel {
}
}
struct FederalClub: Identifiable {
var id: String { federalClubCode }
var federalClubCode: String
var federalClubName: String
}

@ -12,9 +12,7 @@ struct SeedInterval: Hashable, Comparable {
let last: Int
func pointsRange(tournamentLevel: TournamentLevel, teamsCount: Int) -> String {
let range = [tournamentLevel.points(for: last - 1, count: teamsCount),
tournamentLevel.points(for: first - 1, count: teamsCount)]
return range.map { $0.formatted(.number.sign(strategy: .always())) }.joined(separator: " / ") + " pts"
tournamentLevel.pointsRange(first: first, last: last, teamsCount: teamsCount)
}
static func <(lhs: SeedInterval, rhs: SeedInterval) -> Bool {

@ -14,9 +14,14 @@ protocol Selectable {
func badgeImage() -> Badge?
func badgeValueColor() -> Color?
func displayImageIfValueZero() -> Bool
func systemImage() -> String?
}
extension Selectable {
func systemImage() -> String? {
return nil
}
func displayImageIfValueZero() -> Bool {
return false
}

@ -43,7 +43,7 @@ struct CallMessageCustomizationView: View {
}
var computedMessage: String {
[entryFeeMessage, customCallMessageBody].compacted().map { $0.trimmed }.joined(separator: "\n")
[entryFeeMessage, customCallMessageBody].compacted().map { $0.trimmedMultiline }.joined(separator: "\n")
}
var finalMessage: String? {

@ -64,7 +64,7 @@ struct EventCreationView: View {
}
}
TextField("Nom de l'événement", text: $eventName, axis: .vertical)
TextField("Description de l'événement", text: $eventName, axis: .vertical)
.lineLimit(2)
.keyboardType(.alphabet)
.multilineTextAlignment(.leading)

@ -46,7 +46,7 @@ struct EventSettingsView: View {
var body: some View {
Form {
Section {
TextField("Nom de l'événement", text: $eventName, axis: .vertical)
TextField("Description de l'événement", text: $eventName, axis: .vertical)
.lineLimit(2)
.keyboardType(.alphabet)
.multilineTextAlignment(.leading)

@ -23,6 +23,7 @@ struct GenericDestinationPickerView<T: Identifiable & Selectable & Equatable >:
} label: {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(selectedDestination == nil ? .white : .black)
.contentShape(Capsule())
}
.padding()
.background {
@ -38,8 +39,15 @@ struct GenericDestinationPickerView<T: Identifiable & Selectable & Equatable >:
Button {
selectedDestination = destination
} label: {
if let systemImage = destination.systemImage() {
Image(systemName: systemImage)
.foregroundStyle(selectedDestination?.id == destination.id ? .white : .black)
.contentShape(Capsule())
} else {
Text(destination.selectionLabel(index: index))
.foregroundStyle(selectedDestination?.id == destination.id ? .white : .black)
.contentShape(Capsule())
}
}
.padding()
.background {

@ -197,12 +197,12 @@ struct GroupStageView: View {
}
}
.listRowView(isActive: team.qualified, color: .master)
.listRowView(isActive: team.isHere(), color: .green, hideColorVariation: true)
.listRowView(isActive: team.isHere() && groupStage.hasEnded() == false, color: .green, hideColorVariation: true)
} else {
VStack(alignment: .leading, spacing: 0) {
Text("#\(index + 1)")
.font(.caption)
TeamPickerView(groupStagePosition: index, teamPicked: { team in
TeamPickerView(groupStagePosition: index, matchTypeContext: .groupStage, teamPicked: { team in
print(team.pasteData())
team.groupStage = groupStage.id
team.groupStagePosition = index

@ -73,6 +73,30 @@ struct GroupStagesSettingsView: View {
}
}
Section {
if tournament.groupStageLoserBracket() == nil {
RowButtonView("Ajouter des matchs de classements", role: .destructive) {
let round = Round(tournament: tournament.id, index: 0, matchFormat: tournament.loserRoundFormat, groupStageLoserBracket: true)
do {
try tournamentStore.rounds.addOrUpdate(instance: round)
} catch {
Logger.error(error)
}
}
} else if let groupStageLoserBracket = tournament.groupStageLoserBracket() {
RowButtonView("Supprimer les matchs de classements", role: .destructive) {
do {
try groupStageLoserBracket.deleteDependencies()
try tournamentStore.rounds.delete(instance: groupStageLoserBracket)
} catch {
Logger.error(error)
}
}
}
}
#if DEBUG
Section {
RowButtonView("delete all group stages") {
@ -81,12 +105,6 @@ struct GroupStagesSettingsView: View {
}
#endif
// NavigationLink {
// LoserGroupStageSettingsView(tournament: tournament)
// } label: {
// Text("Match de perdant de poules")
// }
Section {
RowButtonView("Retirer tous les horaires", role: .destructive) {
let matches = tournament.groupStages().flatMap({ $0._matches() })
@ -156,6 +174,26 @@ struct GroupStagesSettingsView: View {
}
}
Section {
RowButtonView("Retirer tout le monde", role: .destructive) {
tournament.groupStages().forEach { groupStage in
let teams = groupStage.teams()
teams.forEach { team in
team.groupStagePosition = nil
team.groupStage = nil
groupStage._matches().forEach({ $0.updateTeamScores() })
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
}
}
} footer: {
Text("Toutes les équipes seront retirées et les scores des matchs seront perdus.")
}
}
.overlay(alignment: .bottom) {
if generationDone {

@ -9,8 +9,9 @@ import SwiftUI
import LeStorage
struct GroupStagesView: View {
var tournament: Tournament
@State var tournament: Tournament
@State private var selectedDestination: GroupStageDestination?
@EnvironmentObject var dataStore: DataStore
enum GroupStageDestination: Selectable, Identifiable, Equatable {
static func == (lhs: GroupStagesView.GroupStageDestination, rhs: GroupStagesView.GroupStageDestination) -> Bool {
@ -18,12 +19,15 @@ struct GroupStagesView: View {
}
case all(Tournament)
case loserBracket(Round)
case groupStage(GroupStage)
var id: String {
switch self {
case .all:
return "all-group-stage"
case .loserBracket(let loserBracket):
return loserBracket.id
case .groupStage(let groupStage):
return groupStage.id
}
@ -33,6 +37,8 @@ struct GroupStagesView: View {
switch self {
case .all:
return "Tout"
case .loserBracket:
return "Classement"
case .groupStage(let groupStage):
return groupStage.groupStageTitle()
}
@ -42,6 +48,8 @@ struct GroupStagesView: View {
switch self {
case .all:
return nil
case .loserBracket(let loserBracket):
return loserBracket.badgeValue()
case .groupStage(let groupStage):
return groupStage.badgeValue()
}
@ -59,6 +67,9 @@ struct GroupStagesView: View {
} else {
return nil
}
case .loserBracket(let loserBracket):
if loserBracket._matches().isEmpty { return nil }
return loserBracket.badgeImage()
case .groupStage(let groupStage):
return groupStage.badgeImage()
}
@ -71,7 +82,6 @@ struct GroupStagesView: View {
init(tournament: Tournament) {
self.tournament = tournament
if tournament.shouldVerifyGroupStage {
_selectedDestination = State(wrappedValue: nil)
} else if tournament.unsortedTeams().filter({ $0.groupStagePosition != nil }).isEmpty {
@ -87,6 +97,9 @@ struct GroupStagesView: View {
func allDestinations() -> [GroupStageDestination] {
var allDestinations : [GroupStageDestination] = [.all(tournament)]
let groupStageDestinations : [GroupStageDestination] = tournament.groupStages().map { GroupStageDestination.groupStage($0) }
if let loserBracket = tournament.groupStageLoserBracket() {
allDestinations.insert(.loserBracket(loserBracket), at: 0)
}
allDestinations.append(contentsOf: groupStageDestinations)
return allDestinations
}
@ -96,7 +109,7 @@ struct GroupStagesView: View {
GenericDestinationPickerView(selectedDestination: $selectedDestination, destinations: allDestinations(), nilDestinationIsValid: true)
switch selectedDestination {
case .all:
let finishedMatches = tournament.finishedMatches(allMatches)
let finishedMatches = tournament.finishedMatches(allMatches, limit: nil)
List {
if tournament.groupStageAdditionalQualified > 0 {
@ -142,6 +155,8 @@ struct GroupStagesView: View {
.navigationTitle("Toutes les poules")
case .groupStage(let groupStage):
GroupStageView(groupStage: groupStage).id(groupStage.id)
case .loserBracket(let loserBracket):
LoserBracketFromGroupStageView(loserBracket: loserBracket).id(loserBracket.id)
case nil:
GroupStagesSettingsView()
.navigationTitle("Réglages")

@ -0,0 +1,206 @@
//
// LoserBracketFromGroupStageView.swift
// PadelClub
//
// Created by razmig on 07/09/2024.
//
import SwiftUI
import LeStorage
struct LoserBracketFromGroupStageView: View {
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var dataStore: DataStore
@Environment(NavigationViewModel.self) private var navigation: NavigationViewModel
@State var loserBracket: Round
@State private var isEditingLoserBracketGroupStage: Bool
init(loserBracket: Round) {
self.loserBracket = loserBracket
_isEditingLoserBracketGroupStage = .init(wrappedValue: loserBracket._matches().isEmpty)
}
var tournamentStore: TournamentStore {
return self.tournament.tournamentStore
}
var displayableMatches: [Match] {
loserBracket.playedMatches().sorted(by: \.index)
}
var body: some View {
List {
if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false {
Section {
RowButtonView("Ajouter un match", role: .destructive) {
_addNewMatch()
}
}
}
ForEach(displayableMatches) { match in
Section {
MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle)
.environment(\.isEditingTournamentSeed, $isEditingLoserBracketGroupStage)
} header: {
let tournamentTeamCount = tournament.teamCount
let seedIntervalPointRange = tournament.tournamentLevel.pointsRange(first: match.index, last: match.index + displayableMatches.filter({ $0.index == match.index }).count, teamsCount: tournamentTeamCount)
HStack {
Text(match.matchTitle(.wide))
Spacer()
Text(seedIntervalPointRange)
.font(.caption)
}
} footer: {
if isEditingLoserBracketGroupStage == true {
GroupStageLoserBracketMatchFooterView(match: match, samePlaceThanAboveOption: displayableMatches.count > 1)
}
}
}
Section {
if displayableMatches.count > 1 && isEditingLoserBracketGroupStage == true {
Section {
RowButtonView("Effacer tous les matchs", role: .destructive) {
_deleteAllMatches()
}
} footer: {
Text("Efface tous les matchs de classement de poules ci-dessus.")
}
}
}
}
.overlay {
if displayableMatches.isEmpty {
ContentUnavailableView {
Label("Aucun match de classement", systemImage: "figure.tennis")
} description: {
Text("Vous n'avez créé aucun match de classement entre les perdants de poules.")
} actions: {
RowButtonView("Ajouter un match") {
isEditingLoserBracketGroupStage = true
_addNewMatch()
}
.padding(.horizontal)
}
}
}
.headerProminence(.increased)
.navigationTitle("Classement de poules")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if displayableMatches.isEmpty == false {
Button(isEditingLoserBracketGroupStage == true ? "Valider" : "Modifier") {
if isEditingLoserBracketGroupStage == true {
isEditingLoserBracketGroupStage = false
} else {
isEditingLoserBracketGroupStage = true
}
}
}
}
}
}
private func _addNewMatch() {
let currentGroupStageLoserBracketsInitialPlace = tournament.groupStageLoserBracketsInitialPlace()
let placeCount = displayableMatches.isEmpty ? currentGroupStageLoserBracketsInitialPlace : max(currentGroupStageLoserBracketsInitialPlace, displayableMatches.map({ $0.index }).max()! + 2)
let match = Match(round: loserBracket.id, index: placeCount, matchFormat: loserBracket.matchFormat)
match.name = "\(placeCount)\(placeCount.ordinalFormattedSuffix()) place"
do {
try tournamentStore.matches.addOrUpdate(instance: match)
} catch {
Logger.error(error)
}
}
private func _deleteAllMatches() {
let displayableMatches = loserBracket.playedMatches().sorted(by: \.index)
do {
for match in displayableMatches {
try match.deleteDependencies()
}
try tournamentStore.matches.delete(contentOfs: displayableMatches)
} catch {
Logger.error(error)
}
}
}
struct GroupStageLoserBracketMatchFooterView: View {
@Environment(Tournament.self) var tournament: Tournament
@Bindable var match: Match
let samePlaceThanAboveOption: Bool
@State private var selectPlacePlayed: Bool = false
@State private var index: Int = 0
var body: some View {
HStack {
Menu {
if samePlaceThanAboveOption {
Button("Même place qu'au-dessus") {
_updateIndex(match.index-2)
}
}
Button("Choisir la place") {
index = match.index
selectPlacePlayed = true
}
} label: {
Text("Éditer la place jouée")
.underline()
}
Spacer()
FooterButtonView("Effacer", role: .destructive) {
do {
try match.deleteDependencies()
try match.tournamentStore.matches.delete(instance: match)
} catch {
Logger.error(error)
}
}
}
.alert("Place jouée", isPresented: $selectPlacePlayed) {
TextField("Place jouée", value: $index, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.onSubmit {
_updateIndex(index)
}
Button("Confirmer") {
_updateIndex(index)
}
Button("Annuler", role: .cancel) {
}
}
}
private func _updateIndex(_ newIndex: Int) {
let newIndexValidated = max(1,abs(newIndex))
let teamScores = match.teamScores
teamScores.forEach { ts in
if let luckyLoser = ts.luckyLoser {
ts.luckyLoser = (luckyLoser - match.index * 2) % 2 + newIndexValidated * 2
}
}
match.index = newIndexValidated
match.name = "\(newIndexValidated)\(newIndexValidated.ordinalFormattedSuffix()) place"
do {
try match.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores)
} catch {
Logger.error(error)
}
do {
try match.tournamentStore.matches.addOrUpdate(instance: match)
} catch {
Logger.error(error)
}
}
}

@ -1,85 +0,0 @@
//
// LoserGroupStageSettingsView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 29/06/2024.
//
import SwiftUI
extension Round {
var isGroupStageLoserBracket: Bool {
return false
}
}
extension Tournament {
func groupStageLoserBrackets() -> [Round] {
[]
}
func removeGroupStageLoserBrackets() {
}
}
struct LoserGroupStageSettingsView: View {
var tournament: Tournament
@State private var loserGroupStageBracketType: Int? = nil
@State private var losers : Set<TeamRegistration> = Set()
@Environment(\.editMode) private var editMode
var body: some View {
List(selection: $losers) {
if tournament.groupStageLoserBrackets().isEmpty == false {
//for each all rounds without parent and loserGroupStage, ability to delete them
Section {
RowButtonView("Effacer", role: .destructive) {
tournament.removeGroupStageLoserBrackets()
}
}
}
if self.editMode?.wrappedValue == .active {
Section {
//rajouter + toolbar valider / cancel
ForEach(tournament.groupStageTeams().filter({ $0.qualified == false })) { team in
TeamRowView(team: team).tag(team)
}
} header: {
Text("Sélection des perdants de poules")
}
} else {
Section {
RowButtonView("Ajouter un match de perdant") {
self.editMode?.wrappedValue = .active
}
} footer: {
Text("Permet d'ajouter un match de perdant de poules.")
}
}
}
.toolbar {
if self.editMode?.wrappedValue == .active {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler") {
self.editMode?.wrappedValue = .inactive
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Valider") {
self.editMode?.wrappedValue = .inactive
//tournament.createGroupStageLoserBracket()
}
}
}
}
.navigationTitle("Match de perdant de poules")
.navigationBarBackButtonHidden(self.editMode?.wrappedValue == .active)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
.headerProminence(.increased)
.toolbarBackground(.visible, for: .navigationBar)
}
}

@ -8,7 +8,7 @@
import SwiftUI
struct PlayerBlockView: View {
var match: Match
@State var match: Match
let teamPosition: TeamPosition
let team: TeamRegistration?
let color: Color
@ -52,7 +52,7 @@ struct PlayerBlockView: View {
HStack {
VStack(alignment: .leading) {
if let names {
if let teamScore, teamScore.luckyLoser != nil {
if let teamScore, teamScore.luckyLoser != nil, match.isLoserBracket == false {
Text("Repêchée").italic().font(.caption)
}

@ -24,18 +24,20 @@ struct EditSharingView: View {
func shareMessage(displayRank: Bool, displayTeamName: Bool) -> String {
var messageData: [String] = []
var locAndTime: String = ""
if match.hasEnded() == false {
var locAndTime: String?
if let courtName = match.courtName() {
locAndTime.append("\(courtName)")
locAndTime = "\(courtName)"
}
if let startDate = match.startDate {
locAndTime = locAndTime + " à " + startDate.formattedAsHourMinute()
locAndTime = [locAndTime, startDate.formattedAsHourMinute()].compactMap({ $0 }).joined(separator: " à ")
}
if locAndTime.isEmpty == false {
if let locAndTime, locAndTime.isEmpty == false {
messageData.append(locAndTime)
}
}
if let tournament = match.currentTournament() {
messageData.append(tournament.tournamentTitle())
@ -52,6 +54,8 @@ struct EditSharingView: View {
let players = "\(labelOne)\ncontre\n\(labelTwo)"
messageData.append(players)
messageData.append(match.scoreLabel())
return messageData.joined(separator: "\n")
}

@ -9,7 +9,8 @@ import SwiftUI
struct MatchRowView: View {
var match: Match
@EnvironmentObject var dataStore: DataStore
@State var match: Match
let matchViewStyle: MatchViewStyle
var title: String? = nil

@ -13,13 +13,17 @@ struct MatchSetupView: View {
@EnvironmentObject var dataStore: DataStore
var match: Match
@State var match: Match
@State private var seedGroup: SeedInterval?
var tournamentStore: TournamentStore {
return match.tournamentStore
}
var matchTypeContext: MatchType {
match.matchType
}
@ViewBuilder
var body: some View {
ForEach(TeamPosition.allCases) { teamPosition in
@ -38,7 +42,7 @@ struct MatchSetupView: View {
if let team, teamScore?.walkOut == nil {
VStack(alignment: .leading, spacing: 0) {
if let teamScore, teamScore.luckyLoser != nil {
if let teamScore, teamScore.luckyLoser != nil, match.isLoserBracket == false {
Text("Repêchée").italic().font(.caption)
}
Menu {
@ -59,9 +63,9 @@ struct MatchSetupView: View {
}
HStack {
let luckyLosers = walkOutSpot ? match.luckyLosers() : []
TeamPickerView(shouldConfirm: shouldConfirm, groupStagePosition: nil, luckyLosers: luckyLosers, teamPicked: { team in
TeamPickerView(shouldConfirm: shouldConfirm, groupStagePosition: nil, matchTypeContext: matchTypeContext, luckyLosers: luckyLosers, teamPicked: { team in
print(team.pasteData())
if walkOutSpot || team.bracketPosition != nil {
if walkOutSpot || team.bracketPosition != nil || matchTypeContext == .loserBracket {
match.setLuckyLoser(team: team, teamPosition: teamPosition)
do {
try tournamentStore.matches.addOrUpdate(instance: match)
@ -82,7 +86,7 @@ struct MatchSetupView: View {
}
}
})
if let tournament = match.currentTournament() {
if matchTypeContext == .bracket, let tournament = match.currentTournament() {
let availableQualifiedTeams = tournament.availableQualifiedTeams()
let availableSeedGroups = tournament.availableSeedGroups()
Text("ou")
@ -146,6 +150,8 @@ struct MatchSetupView: View {
.underline()
}
.disabled(availableSeedGroups.isEmpty && walkOutSpot == false && availableQualifiedTeams.isEmpty)
if matchTypeContext == .bracket {
Spacer()
if match.isSeedLocked(atTeamPosition: teamPosition) {
Button {
@ -174,6 +180,7 @@ struct MatchSetupView: View {
}
}
}
}
.fixedSize(horizontal: false, vertical: true)
.buttonStyle(.borderless)
}
@ -207,6 +214,14 @@ struct MatchSetupView: View {
} catch {
Logger.error(error)
}
} else if match.isLoserBracket {
if let score = match.teamScore(ofTeam: team) {
do {
try tournamentStore.teamScores.delete(instance: score)
} catch {
Logger.error(error)
}
}
} else {
match.teamWillBeWalkOut(team)
do {

@ -8,7 +8,8 @@
import SwiftUI
struct MatchSummaryView: View {
var match: Match
@EnvironmentObject var dataStore: DataStore
@State var match: Match
let matchViewStyle: MatchViewStyle
let matchTitle: String
let roundTitle: String?

@ -16,12 +16,13 @@ struct ActivityView: View {
@State private var presentFilterView: Bool = false
@State private var presentToolbar: Bool = false
@State private var newTournament: Tournament?
@State private var viewStyle: AgendaDestination.ViewStyle = .list
@State private var viewStyle: ViewStyle = .list
@State private var isGatheringFederalTournaments: Bool = false
@State private var error: Error?
@State private var uuid: UUID = UUID()
@State private var presentClubSearchView: Bool = false
@State private var quickAccessScreen: QuickAccessScreen? = nil
@State private var displaySearchView: Bool = false
enum QuickAccessScreen : Identifiable, Hashable {
case inscription(pasteString: String)
@ -67,6 +68,8 @@ struct ActivityView: View {
return endedTournaments
case .tenup:
return federalDataViewModel.filteredFederalTournaments
case .around:
return federalDataViewModel.filteredSearchedFederalTournaments
}
}
@ -87,17 +90,27 @@ struct ActivityView: View {
VStack(spacing: 0) {
GenericDestinationPickerView(selectedDestination: $navigation.agendaDestination, destinations: AgendaDestination.allCases, nilDestinationIsValid: false)
ScrollViewReader { proxy in
List {
switch navigation.agendaDestination! {
case .activity:
EventListView(tournaments: runningTournaments, viewStyle: viewStyle, sortAscending: true)
EventListView(tournaments: runningTournaments, sortAscending: true)
case .history:
EventListView(tournaments: endedTournaments, viewStyle: viewStyle, sortAscending: false)
EventListView(tournaments: endedTournaments, sortAscending: false)
case .tenup:
EventListView(tournaments: federalDataViewModel.federalTournaments, viewStyle: viewStyle, sortAscending: true)
EventListView(tournaments: federalDataViewModel.federalTournaments, sortAscending: true)
.id(uuid)
case .around:
EventListView(tournaments: federalDataViewModel.searchedFederalTournaments, sortAscending: true)
}
}
.onChange(of: navigation.agendaDestination) {
withAnimation {
proxy.scrollTo(0, anchor: .center)
}
}
}
.environment(\.viewStyle, viewStyle)
.environment(federalDataViewModel)
.overlay {
if let error, navigation.agendaDestination == .tenup {
@ -119,14 +132,18 @@ struct ActivityView: View {
ContentUnavailableView.search(text: searchText)
} else if federalDataViewModel.areFiltersEnabled() {
ContentUnavailableView {
Text("Aucun résultat")
Text("Aucun tournoi")
} description: {
Text(federalDataViewModel.filterStatus())
Text("Aucun tournoi ne correspond aux fitres que vous avez choisis : \(federalDataViewModel.filterStatus())")
} actions: {
RowButtonView("supprimer le filtre") {
FooterButtonView("supprimer vos filtres") {
federalDataViewModel.removeFilters()
}
.padding(.horizontal)
FooterButtonView("modifier vos filtres") {
presentFilterView = true
}
.padding(.horizontal)
}
} else {
_dataEmptyView()
@ -134,14 +151,10 @@ struct ActivityView: View {
}
}
}
}
//.searchable(text: $searchText)
.onAppear { presentToolbar = true }
.onDisappear { presentToolbar = false }
.sheet(item: $newTournament) { tournament in
EventCreationView(tournaments: [tournament], selectedClub: federalDataViewModel.selectedClub())
.environment(navigation)
.tint(.master)
}
.refreshable {
if navigation.agendaDestination == .tenup {
federalDataViewModel.federalTournaments.removeAll()
@ -157,21 +170,29 @@ struct ActivityView: View {
}
}
.onChange(of: navigation.agendaDestination) {
if tournaments.isEmpty, viewStyle == .calendar {
viewStyle = .list
}
if navigation.agendaDestination == .tenup
&& dataStore.user.hasTenupClubs() == true
&& federalDataViewModel.federalTournaments.isEmpty {
_gatherFederalTournaments()
}
}
.toolbar {
if presentToolbar {
//let _activityStatus = _activityStatus()
if federalDataViewModel.areFiltersEnabled() {
ToolbarItem(placement: .status) {
Text(federalDataViewModel.filterStatus())
.onChange(of: presentFilterView, { old, new in
if old == true, new == false { //closing filter view
if tournaments.isEmpty, viewStyle == .calendar {
viewStyle = .list
}
}
})
.toolbarTitleDisplayMode(.large)
.navigationTitle(TabDestination.activity.title)
.navigationDestination(for: Tournament.self) { tournament in
TournamentView(tournament: tournament)
}
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Button {
switch viewStyle {
@ -184,7 +205,7 @@ struct ActivityView: View {
Image(systemName: "calendar.circle")
.resizable()
.scaledToFit()
.frame(minHeight: 28)
.frame(minHeight: 32)
}
.symbolVariant(viewStyle == .calendar ? .fill : .none)
@ -194,7 +215,7 @@ struct ActivityView: View {
Image(systemName: "line.3.horizontal.decrease.circle")
.resizable()
.scaledToFit()
.frame(minHeight: 28)
.frame(minHeight: 32)
}
.symbolVariant(federalDataViewModel.areFiltersEnabled() ? .fill : .none)
@ -209,14 +230,42 @@ struct ActivityView: View {
Image(systemName: "plus.circle.fill")
.resizable()
.scaledToFit()
.frame(minHeight: 28)
.frame(minHeight: 32)
}
}
if presentToolbar, tournaments.isEmpty == false, federalDataViewModel.areFiltersEnabled() || navigation.agendaDestination == .around {
ToolbarItemGroup(placement: .bottomBar) {
VStack(spacing: 0) {
let searchStatus = _searchStatus()
if searchStatus.isEmpty == false {
Text(_searchStatus())
.font(.footnote)
.foregroundStyle(.secondary)
}
HStack {
if navigation.agendaDestination == .around {
FooterButtonView("modifier votre recherche") {
displaySearchView = true
}
if federalDataViewModel.areFiltersEnabled() {
Text("ou")
}
}
if federalDataViewModel.areFiltersEnabled() {
FooterButtonView(_filterButtonTitle()) {
presentFilterView = true
}
}
}
.padding(.bottom, 8)
}
}
}
.navigationTitle(TabDestination.activity.title)
.navigationDestination(for: Tournament.self) { tournament in
TournamentView(tournament: tournament)
}
.sheet(isPresented: $presentFilterView) {
TournamentFilterView(federalDataViewModel: federalDataViewModel)
@ -232,7 +281,16 @@ struct ActivityView: View {
ClubImportView()
.tint(.master)
}
.sheet(isPresented: $displaySearchView) {
NavigationStack {
TournamentLookUpView()
.environment(federalDataViewModel)
}
}
.sheet(item: $newTournament) { tournament in
EventCreationView(tournaments: [tournament], selectedClub: federalDataViewModel.selectedClub())
.environment(navigation)
.tint(.master)
}
.sheet(item: $quickAccessScreen) { screen in
switch screen {
@ -274,6 +332,31 @@ struct ActivityView: View {
}
}
}
}
private func _searchStatus() -> String {
var searchStatus : [String] = []
if navigation.agendaDestination == .around, federalDataViewModel.searchedFederalTournaments.isEmpty == false {
let filteredSearchedFederalTournaments = federalDataViewModel.filteredSearchedFederalTournaments
let status : String = filteredSearchedFederalTournaments.count.formatted() + " tournoi" + filteredSearchedFederalTournaments.count.pluralSuffix
searchStatus.append(status)
}
if federalDataViewModel.areFiltersEnabled(), tournaments.isEmpty == false {
searchStatus.append(federalDataViewModel.filterStatus())
}
return searchStatus.joined(separator: " ")
}
private func _filterButtonTitle() -> String {
var prefix = "modifier "
if navigation.agendaDestination == .around, federalDataViewModel.searchedFederalTournaments.isEmpty == false {
prefix = ""
}
return prefix + "vos filtres"
}
private func _gatherFederalTournaments() {
isGatheringFederalTournaments = true
@ -298,6 +381,8 @@ struct ActivityView: View {
_endedEmptyView()
case .tenup:
_tenupEmptyView()
case .around:
_searchTenupEmptyView()
}
}
@ -363,6 +448,33 @@ struct ActivityView: View {
}
}
@ViewBuilder
private func _searchTenupEmptyView() -> some View {
if federalDataViewModel.searchAttemptCount == 0 {
ContentUnavailableView {
Label("Recherche de tournoi", systemImage: "magnifyingglass")
} description: {
Text("Chercher les tournois autour de vous pour mieux décider les tournois à proposer dans votre club. Padel Club vous facilite même l'inscription !")
} actions: {
RowButtonView("Lancer la recherche") {
displaySearchView = true
}
.padding()
}
} else {
ContentUnavailableView {
Label("Aucun tournoi", systemImage: "shield.slash")
} description: {
Text("Aucun tournoi ne correspond aux critères sélectionnés.")
} actions: {
FooterButtonView("modifier vos critères de recherche") {
displaySearchView = true
}
.padding()
}
}
}
}
//#Preview {

@ -91,11 +91,18 @@ struct CalendarView: View {
Menu {
ForEach(tournament.tournaments, id: \.id) { build in
if federalDataViewModel.isFederalTournamentValidForFilters(tournament, build: build) {
if navigation.agendaDestination == .around {
NavigationLink(build.buildHolderTitle()) {
TournamentSubscriptionView(federalTournament: tournament, build: build, user: dataStore.user)
}
} else {
Button(build.buildHolderTitle()) {
_createOrShow(federalTournament: tournament, existingTournament: event(forTournament: tournament)?.existingBuild(build), build: build)
}
}
}
}
} label: {
Text(tournament.clubLabel())
Text("sur " + tournament.dayDuration.formatted() + " jour" + tournament.dayDuration.pluralSuffix)

@ -12,16 +12,19 @@ struct EventListView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(NavigationViewModel.self) var navigation: NavigationViewModel
@Environment(FederalDataViewModel.self) var federalDataViewModel: FederalDataViewModel
@Environment(\.viewStyle) var viewStyle
let tournaments: [FederalTournamentHolder]
let viewStyle: AgendaDestination.ViewStyle
let sortAscending: Bool
var body: some View {
let groupedTournamentsByDate = Dictionary(grouping: navigation.agendaDestination == .tenup ? federalDataViewModel.filteredFederalTournaments : tournaments) { $0.startDate.startOfMonth }
let groupedTournamentsByDate = Dictionary(grouping: federalDataViewModel.filteredFederalTournaments(from: tournaments)) { $0.startDate.startOfMonth }
switch viewStyle {
case .list:
ForEach(groupedTournamentsByDate.keys.sorted(by: sortAscending ? { $0 < $1 } : { $0 > $1 }), id: \.self) { section in
let nextMonths = groupedTournamentsByDate.keys.sorted(by: sortAscending ? { $0 < $1 } : { $0 > $1 })
ForEach(nextMonths.indices, id: \.self) { sectionIndex in
let section = nextMonths[sectionIndex]
if let _tournaments = groupedTournamentsByDate[section]?.sorted(by: sortAscending ? { $0.startDate < $1.startDate } : { $0.startDate > $1.startDate }
) {
Section {
@ -34,11 +37,14 @@ struct EventListView: View {
Text("\(count.formatted()) tournoi" + count.pluralSuffix)
}
}
.id(sectionIndex)
.headerProminence(.increased)
}
}
case .calendar:
ForEach(_nextMonths(), id: \.self) { section in
let nextMonths = _nextMonths()
ForEach(nextMonths.indices, id: \.self) { sectionIndex in
let section = nextMonths[sectionIndex]
let _tournaments = groupedTournamentsByDate[section] ?? []
Section {
CalendarView(date: section, tournaments: _tournaments).id(federalDataViewModel.id)
@ -50,6 +56,7 @@ struct EventListView: View {
Text("\(count.formatted()) tournoi" + count.pluralSuffix)
}
}
.id(sectionIndex)
.headerProminence(.increased)
.task {
if navigation.agendaDestination == .tenup
@ -77,16 +84,25 @@ struct EventListView: View {
}
private func _nextMonths() -> [Date] {
var result: [Date] = []
var currentDate = Date().startOfMonth
let currentDate = Date().startOfMonth
let uniqueDates = tournaments.map { $0.startDate.startOfMonth }.uniqued().sorted()
let firstMonthOfDate = uniqueDates.first
let lastMonthOfDate = uniqueDates.last
let calendar = Calendar.current
// Generate 100 future months
for _ in 0..<12 {
result.append(currentDate)
currentDate = Calendar.current.date(byAdding: .month, value: 1, to: currentDate)!
if let firstMonthOfDate, let lastMonthOfDate {
if navigation.agendaDestination == .history {
return calendar.generateMonthRange(startDate: firstMonthOfDate, endDate: lastMonthOfDate).reversed()
} else if navigation.agendaDestination == .around || navigation.agendaDestination == .tenup {
return calendar.generateMonthRange(startDate: firstMonthOfDate, endDate: lastMonthOfDate)
} else {
let min = min(currentDate, firstMonthOfDate)
let max = max(currentDate, lastMonthOfDate)
return calendar.generateMonthRange(startDate: min, endDate: calendar.addMonths(3, to: max))
}
} else {
return calendar.generateMonthRange(startDate: currentDate, endDate: calendar.addMonths(3, to: currentDate))
}
return result
}
private func _listView(_ tournaments: [FederalTournamentHolder]) -> some View {

@ -0,0 +1,483 @@
//
// TournamentLookUpView.swift
// PadelClub
//
// Created by razmig on 08/09/2024.
//
import SwiftUI
import CoreLocation
import CoreLocationUI
struct TournamentLookUpView: View {
@Environment(FederalDataViewModel.self) var federalDataViewModel: FederalDataViewModel
@StateObject var locationManager = LocationManager()
@Environment(\.dismiss) private var dismiss
@State private var searchField: String = ""
@State var page: Int = 0
@State var total: Int = 0
@State private var tournamentCategories = Set<TournamentCategory.ID>()
@State private var tournamentLevels = Set<TournamentLevel.ID>()
@State private var tournamentAges = Set<FederalTournamentAge.ID>()
@State private var tournamentTypes = Set<FederalTournamentType.ID>()
@State private var searching: Bool = false
@State private var startDate: Date = Date()
@State private var endDate: Date = Calendar.current.date(byAdding: .month, value: 3, to: Date())!
@AppStorage("lastCity") private var city: String = ""
@State private var ligue: String = ""
@State private var distance: Double = 30
@State private var sortingOption: String = "dateDebut+asc"
@State private var requestedToGetAllPages: Bool = false
@State private var nationalCup: Bool = false
@State private var revealSearchParameters: Bool = true
@State private var presentAlert: Bool = false
var tournaments: [FederalTournament] {
federalDataViewModel.searchedFederalTournaments
}
var body: some View {
List {
searchParametersView
}
.alert("Attention", isPresented: $presentAlert, actions: {
Button {
presentAlert = false
requestedToGetAllPages = true
page += 1
searching = true
Task {
await getNewPage()
searching = false
dismiss()
}
} label: {
Label("Tout voir", systemImage: "arrow.down.circle")
}
Button("Annuler") {
revealSearchParameters = true
presentAlert = false
}
}, message: {
Text("Il y a beacoup de tournois pour cette requête, êtes-vous sûr de vouloir tout récupérer ? Sinon essayez d'affiner votre recherche.")
})
.toolbarBackground(.visible, for: .bottomBar, .navigationBar)
.navigationTitle("Chercher un tournoi")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: locationManager.city) {
if let newValue = locationManager.city, city.isEmpty {
city = newValue
}
}
.toolbarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .bottomBar) {
if revealSearchParameters {
FooterButtonView("Lancer la recherche") {
runSearch()
}
.disabled(searching)
} else if searching {
HStack(spacing: 20) {
Spacer()
ProgressView()
if total > 0 {
let percent = Double(tournaments.count) / Double(total)
Text(percent.formatted(.percent.precision(.significantDigits(1...3))) + " en récupération de Tenup")
.font(.caption)
}
Spacer()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
#if DEBUG
if tournaments.isEmpty == false {
Section {
ShareLink(item: pastedTournaments) {
Label("Par texte", systemImage: "square.and.arrow.up")
.labelStyle(.titleAndIcon)
}
ShareLink(item: japList) {
Label("JAP liste", systemImage: "square.and.arrow.up")
.labelStyle(.titleAndIcon)
}
} header: {
Text("Partager les résultats")
}
}
Divider()
#endif
Button(role: .destructive) {
tournamentLevels = Set()
tournamentCategories = Set()
city = ""
locationManager.location = nil
locationManager.city = nil
distance = 30
startDate = Date()
endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())!
sortingOption = "dateDebut+asc"
revealSearchParameters = true
federalDataViewModel.searchedFederalTournaments = []
federalDataViewModel.searchAttemptCount = 0
} label: {
Text("Ré-initialiser la recherche")
}
} label: {
Label("Options", systemImage: "ellipsis.circle")
}
}
}
}
var pastedTournaments: String {
tournaments.map { $0.shareMessage }.joined()
}
var japList: String {
Set(tournaments.map { $0.japMessage }).joined(separator: "\n")
}
private var clubsFound: [String] {
Set(tournaments.compactMap { $0.nomClub }).sorted()
}
private var liguesFound: [String] {
Set(tournaments.compactMap { $0.nomLigue }).sorted()
}
private func runSearch() {
revealSearchParameters = false
total = 0
page = 0
federalDataViewModel.searchedFederalTournaments = []
searching = true
requestedToGetAllPages = false
federalDataViewModel.searchAttemptCount += 1
Task {
await getNewPage()
searching = false
if tournaments.isEmpty == false && tournaments.count < total && total >= 200 && requestedToGetAllPages == false {
presentAlert = true
} else {
dismiss()
}
}
}
private var distanceLimit: Measurement<UnitLength> {
distanceLimit(distance: distance)
}
private func distanceLimit(distance: Double) -> Measurement<UnitLength> {
Measurement(value: distance, unit: .kilometers)
}
private var categories: [TournamentCategory] {
tournamentCategories.compactMap { TournamentCategory(rawValue: $0) }
}
private var levels: [TournamentLevel] {
tournamentLevels.compactMap { TournamentLevel(rawValue: $0) }
}
private var ages: [FederalTournamentAge] {
tournamentAges.compactMap { FederalTournamentAge(rawValue: $0) }
}
private var types: [FederalTournamentType] {
tournamentTypes.compactMap { FederalTournamentType(rawValue: $0) }
}
func getNewPage() async {
do {
if NetworkFederalService.shared.formId.isEmpty {
await getNewBuildForm()
} else {
let commands = try await NetworkFederalService.shared.getAllFederalTournaments(sortingOption: sortingOption, page: page, startDate: startDate, endDate: endDate, city: city, distance: distance, categories: categories, levels: levels, lat: locationManager.location?.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us"))), lng: locationManager.location?.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us"))), ages: ages, types: types, nationalCup: nationalCup)
let resultCommand = commands.first(where: { $0.results != nil })
if let newTournaments = resultCommand?.results?.items {
newTournaments.forEach { ft in
if tournaments.contains(where: { $0.id == ft.id }) == false {
federalDataViewModel.searchedFederalTournaments.append(ft)
}
}
}
if let count = resultCommand?.results?.nb_results {
print("count", count, total, tournaments.count, page)
total = count
if tournaments.count < count && page < total / 30 {
if total < 200 || requestedToGetAllPages {
page += 1
await getNewPage()
}
} else {
print("finished")
}
} else {
print("total results not found")
}
}
} catch {
print("getNewPage", error)
await getNewBuildForm()
}
}
func getNewBuildForm() async {
do {
try await NetworkFederalService.shared.getNewBuildForm()
await getNewPage()
} catch {
print("getNewBuildForm", error)
}
}
@ViewBuilder
var searchContollerView: some View {
Section {
Button {
runSearch()
} label: {
HStack {
Label("Chercher un tournoi", systemImage: "magnifyingglass")
if searching {
Spacer()
ProgressView()
}
}
}
Button {
tournamentLevels = Set()
tournamentCategories = Set()
city = ""
locationManager.location = nil
locationManager.city = nil
distance = 30
startDate = Date()
endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())!
sortingOption = "dateDebut+asc"
revealSearchParameters = true
} label: {
Label("Ré-initialiser la recherche", systemImage: "xmark.circle")
}
}
}
@ViewBuilder
var searchParametersView: some View {
@Bindable var federalDataViewModel = federalDataViewModel
Section {
DatePicker("Début", selection: $startDate, displayedComponents: .date)
DatePicker("Fin", selection: $endDate, displayedComponents: .date)
Picker(selection: $federalDataViewModel.dayDuration) {
Text("aucune").tag(nil as Int?)
Text(1.formatted()).tag(1 as Int?)
Text(2.formatted()).tag(2 as Int?)
Text(3.formatted()).tag(3 as Int?)
} label: {
Text("Durée max (en jours)")
}
Picker(selection: $federalDataViewModel.dayPeriod) {
ForEach(DayPeriod.allCases) {
Text($0.localizedDayPeriodLabel()).tag($0)
}
} label: {
Text("En semaine ou week-end")
}
HStack {
TextField("Ville", text: $city)
if let city = locationManager.city {
Divider()
Text(city).italic()
}
if locationManager.requestStarted {
ProgressView()
} else {
LocationButton {
locationManager.requestLocation()
}
.symbolVariant(.fill)
.foregroundColor (Color.white)
.cornerRadius (20)
.font(.system(size: 12))
}
}
Picker(selection: $distance) {
Text(distanceLimit(distance:30).formatted()).tag(30.0)
Text(distanceLimit(distance:50).formatted()).tag(50.0)
Text(distanceLimit(distance:60).formatted()).tag(60.0)
Text(distanceLimit(distance:90).formatted()).tag(90.0)
Text(distanceLimit(distance:150).formatted()).tag(150.0)
Text(distanceLimit(distance:200).formatted()).tag(200.0)
Text(distanceLimit(distance:400).formatted()).tag(400.0)
Text("Aucune").tag(3000.0)
} label: {
Text("Distance max")
}
Picker(selection: $sortingOption) {
Text("Distance").tag("_DIST_")
Text("Date de début").tag("dateDebut+asc")
Text("Date de fin").tag("dateFin+asc")
} label: {
Text("Trier par")
}
NavigationLink {
List(TournamentCategory.allCases, selection: $tournamentCategories) { type in
Text(type.localizedLabel())
}
.navigationTitle("Catégories")
.environment(\.editMode, Binding.constant(EditMode.active))
} label: {
HStack {
Text("Catégorie")
Spacer()
categoriesLabel
.foregroundStyle(.secondary)
}
}
NavigationLink {
List(TournamentLevel.allCases, selection: $tournamentLevels) { type in
Text(type.localizedLabel())
}
.navigationTitle("Niveaux")
.environment(\.editMode, Binding.constant(EditMode.active))
} label: {
HStack {
Text("Niveau")
Spacer()
levelsLabel
.foregroundStyle(.secondary)
}
}
NavigationLink {
List(FederalTournamentAge.allCases, selection: $tournamentAges) { type in
Text(type.localizedLabel())
}
.navigationTitle("Limites d'âge")
.environment(\.editMode, Binding.constant(EditMode.active))
} label: {
HStack {
Text("Limite d'âge")
Spacer()
if tournamentAges.isEmpty || tournamentAges.count == FederalTournamentAge.allCases.count {
Text("Tous les âges")
.foregroundStyle(.secondary)
} else {
Text(ages.map({ $0.localizedLabel()}).joined(separator: ", "))
.foregroundStyle(.secondary)
}
}
}
NavigationLink {
List(FederalTournamentType.allCases, selection: $tournamentTypes) { type in
Text(type.localizedLabel())
}
.navigationTitle("Types de tournoi")
.environment(\.editMode, Binding.constant(EditMode.active))
} label: {
HStack {
Text("Type de tournoi")
Spacer()
if tournamentTypes.isEmpty || tournamentTypes.count == FederalTournamentType.allCases.count {
Text("Tous les types")
.foregroundStyle(.secondary)
} else {
Text(types.map({ $0.localizedLabel()}).joined(separator: ", "))
.foregroundStyle(.secondary)
}
}
}
Picker(selection: $nationalCup) {
Text("N'importe").tag(false)
Text("Uniquement").tag(true)
} label: {
Text("Circuit National Padel Cup")
}
} header: {
Text("Critères de recherche")
}
.headerProminence(.increased)
.disabled(searching)
}
var categoriesLabel: some View {
if tournamentCategories.isEmpty || tournamentCategories.count == TournamentCategory.allCases.count {
Text("Toutes les catégories")
} else {
Text(categories.map({ $0.localizedLabel() }).joined(separator: ", "))
}
}
var levelsLabel: some View {
if tournamentLevels.isEmpty || tournamentLevels.count == TournamentLevel.allCases.count {
Text("Tous les niveaux")
} else {
Text(levels.map({ $0.localizedLabel() }).joined(separator: ", "))
}
}
@ViewBuilder
var searchParametersSummaryView: some View {
VStack(alignment: .leading) {
HStack {
Text("Lieu")
Spacer()
Text(city)
if distance >= 3000 {
Text("sans limite de distance")
} else {
Text("à moins de " + distanceLimit.formatted())
}
}
HStack {
Text("Période")
Spacer()
Text("Du")
Text(startDate.twoDigitsYearFormatted)
Text("Au")
Text(endDate.twoDigitsYearFormatted)
}
HStack {
Text("Niveau")
Spacer()
levelsLabel
}
HStack {
Text("Catégorie")
Spacer()
categoriesLabel
}
HStack {
Text("Tri")
Spacer()
Text(sortingOptionLabel)
}
}
}
var sortingOptionLabel: String {
switch sortingOption {
case "_DIST_": return "Distance"
case "dateDebut+asc": return "Date de début"
case "dateFin+asc": return "Date de fin"
default:
return "Distance"
}
}
}

@ -0,0 +1,232 @@
//
// TournamentSubscriptionView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 09/09/2024.
//
import SwiftUI
struct TournamentSubscriptionView: View {
@EnvironmentObject var networkMonitor: NetworkMonitor
let federalTournament: FederalTournament
let build: any TournamentBuildHolder
let user: User
@State private var selectedPlayers: [ImportedPlayer]
@State private var contactType: ContactType? = nil
@State private var sentError: ContactManagerError? = nil
init(federalTournament: FederalTournament, build: any TournamentBuildHolder, user: User) {
self.federalTournament = federalTournament
self.build = build
self.user = user
_selectedPlayers = .init(wrappedValue: [user.currentPlayerData()].compactMap({ $0 }))
}
var body: some View {
List {
Section {
LabeledContent("Tournoi") {
Text(federalTournament.libelle ?? "Tournoi")
}
LabeledContent("Club") {
Text(federalTournament.clubLabel())
}
LabeledContent("Épreuve") {
Text(build.buildHolderTitle())
}
LabeledContent("JAP") {
Text(federalTournament.umpireLabel())
}
LabeledContent("Mail") {
Text(federalTournament.mailLabel())
}
LabeledContent("Téléphone") {
Text(federalTournament.phoneLabel())
}
} header: {
Text("Informations")
}
Section {
ForEach(selectedPlayers) { teamPlayer in
NavigationLink {
SelectablePlayerListView(allowSelection: 1, playerSelectionAction: { players in
if let player = players.first {
selectedPlayers.remove(elements: [teamPlayer])
selectedPlayers.append(player)
}
})
} label: {
ImportedPlayerView(player: teamPlayer)
}
}
if selectedPlayers.count < 2 {
NavigationLink {
SelectablePlayerListView(allowSelection: 1, playerSelectionAction: { players in
if let player = players.first {
selectedPlayers.append(player)
}
})
} label: {
Text("Choisir un partenaire")
}
}
} header: {
if selectedPlayers.isEmpty == false {
HStack {
Text("Poids de l'équipe")
Spacer()
Text(selectedPlayers.map { $0.rank }.reduce(0, +).formatted())
}
}
}
if let courrielEngagement = federalTournament.courrielEngagement {
Section {
RowButtonView("S'inscrire par email") {
contactType = .mail(date: nil, recipients: [courrielEngagement], bccRecipients: nil, body: messageBody, subject: messageSubject, tournamentBuild: build as? TournamentBuild)
}
}
}
if let installation = federalTournament.installation, let telephone = installation.telephone {
if telephone.isMobileNumber() {
Section {
RowButtonView("S'inscrire par message") {
contactType = .message(date: nil, recipients: [telephone], body: messageBodyShort, tournamentBuild: build as? TournamentBuild)
}
}
}
let number = telephone.replacingOccurrences(of: " ", with: "")
if let url = URL(string: "tel:\(number)") {
Link(destination: url) {
Label("Appeler", systemImage: "phone")
}
}
Section {
Text(messageBody)
} header: {
Text("Message preparé par Padel Club")
} footer: {
CopyPasteButtonView(pasteValue: messageBody)
}
}
}
.toolbar(content: {
Menu {
Link(destination: URL(string:"https://tenup.fft.fr/tournoi/\(federalTournament.id)")!) {
Label("Voir sur Tenup", systemImage: "tennisball")
}
ShareLink(item: federalTournament.shareMessage) {
Label("Partager les infos", systemImage: "info")
}
} label: {
LabelOptions()
}
})
.alert("Un problème est survenu", isPresented: messageSentFailed) {
Button("OK") {
}
} message: {
Text(_networkErrorMessage)
}
.sheet(item: $contactType) { contactType in
Group {
switch contactType {
case .message(_, let recipients, let body, _):
MessageComposeView(recipients: recipients, body: body) { result in
switch result {
case .cancelled:
break
case .failed:
self.sentError = .messageFailed
case .sent:
if networkMonitor.connected == false {
self.sentError = .messageNotSent
}
@unknown default:
break
}
}
case .mail(_, let recipients, let bccRecipients, let body, let subject, _):
MailComposeView(recipients: recipients, bccRecipients: bccRecipients, body: body, subject: subject) { result in
switch result {
case .cancelled, .saved:
self.contactType = nil
case .failed:
self.contactType = nil
self.sentError = .mailFailed
case .sent:
if networkMonitor.connected == false {
self.contactType = nil
self.sentError = .mailNotSent
}
@unknown default:
break
}
}
}
}
.tint(.master)
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Détail du tournoi")
}
var teamsString: String {
selectedPlayers.map { $0.pasteData() }.joined(separator: "\n")
}
var messageBody: String {
let bonjourOuBonsoir = Date().timeOfDay.hello
let bonneSoireeOuBonneJournee = Date().timeOfDay.goodbye
let body = [["\(bonjourOuBonsoir),\n\nJe souhaiterais inscrire mon équipe au tournoi : ", build.buildHolderTitle(), "du", federalTournament.computedStartDate, "au", federalTournament.clubLabel() + ".\n"].compacted().joined(separator: " "), teamsString, "\nCordialement,\n", user.fullName() ?? bonneSoireeOuBonneJournee, "----------------------------------\nCe message a été préparé grâce à l'application Padel Club !\nVotre tournoi n'est pas encore dessus ? \(URLs.main.rawValue)", "Téléchargez l'app : \(URLs.appStore.rawValue)", "En savoir plus : \(URLs.appDescription.rawValue)"].compactMap { $0 }.joined(separator: "\n") + "\n"
return body
}
var messageBodyShort: String {
let body = [[build.buildHolderTitle(), federalTournament.clubLabel()].compacted().joined(separator: " "), federalTournament.computedStartDate, teamsString].compacted().joined(separator: "\n") + "\n"
return body
}
var messageSubject: String {
let subject = [build.buildHolderTitle(), federalTournament.clubLabel()].compacted().joined(separator: " ")
return subject
}
var messageSentFailed: Binding<Bool> {
Binding {
sentError != nil
} set: { newValue in
if newValue == false {
sentError = nil
}
}
}
private var _networkErrorMessage: String {
var errors: [String] = []
if networkMonitor.connected == false {
errors.append("L'appareil n'est pas connecté à internet.")
}
if sentError == .mailNotSent {
errors.append("Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer.")
}
if (sentError == .messageFailed || sentError == .messageNotSent) {
errors.append("Le SMS n'a pas été envoyé")
}
if sentError == .mailFailed {
errors.append("Le mail n'a pas été envoyé")
}
return errors.joined(separator: "\n")
}
}

@ -16,11 +16,31 @@ struct LoserRoundSettingsView: View {
var body: some View {
List {
Section {
RowButtonView(isEditingTournamentSeed.wrappedValue == true ? "Terminer l'édition" : "Éditer les tours joués") {
isEditingTournamentSeed.wrappedValue.toggle()
}
}
Section {
RowButtonView("Synchroniser les noms des matchs") {
let allRoundMatches = upperBracketRound.loserRounds.flatMap({ $0.allMatches
})
allRoundMatches.forEach({ $0.name = $0.roundTitle() })
do {
try self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches)
} catch {
Logger.error(error)
}
}
}
Section {
RowButtonView("Effacer les matchs de classements", role: .destructive) {
upperBracketRound.round.deleteLoserBracket()
}
}
.disabled(upperBracketRound.round.loserRounds().isEmpty)
Section {
RowButtonView("Créer les matchs de classements", role: .destructive) {
@ -30,12 +50,7 @@ struct LoserRoundSettingsView: View {
}
}
}
Section {
RowButtonView(isEditingTournamentSeed.wrappedValue == true ? "Terminer l'édition" : "Éditer les tours joués") {
isEditingTournamentSeed.wrappedValue.toggle()
}
}
.disabled(upperBracketRound.round.loserRounds().isEmpty == false)
//todo proposer ici l'impression des matchs de classements peut-être?
}

@ -53,13 +53,13 @@ struct LoserRoundView: View {
if isEditingTournamentSeed.wrappedValue == true {
RowButtonView(match.disabled ? "Jouer ce match" : "Ne pas jouer ce match", role: .destructive) {
match._toggleMatchDisableState(!match.disabled)
match._toggleMatchDisableState(!match.disabled, single: true)
}
}
}
} header: {
HStack {
if let seedInterval = loserRound.seedInterval() {
if let seedInterval = loserRound.seedInterval(initialMode: isEditingTournamentSeed.wrappedValue == true) {
Text(seedInterval.localizedLabel(.wide))
let seedIntervalPointRange = seedInterval.pointsRange(tournamentLevel: tournament.tournamentLevel, teamsCount: tournament.teamCount)
Spacer()
@ -67,13 +67,13 @@ struct LoserRoundView: View {
.font(.caption)
} else {
if let previousRound = loserRound.previousRound() {
if let seedInterval = previousRound.seedInterval() {
if let seedInterval = previousRound.seedInterval(initialMode: isEditingTournamentSeed.wrappedValue == true) {
Text(seedInterval.localizedLabel())
} else {
Text("no previous round")
}
} else if let parentRound = loserRound.parentRound {
if let seedInterval = parentRound.seedInterval() {
if let seedInterval = parentRound.seedInterval(initialMode: isEditingTournamentSeed.wrappedValue == true) {
Text(seedInterval.localizedLabel())
} else {
Text("no parent round")

@ -167,6 +167,10 @@ struct LoserRoundsView: View {
init(upperBracketRound: UpperRound) {
self.upperBracketRound = upperBracketRound
_selectedRound = State(wrappedValue: upperBracketRound.loserRounds.first(where: { $0.rounds.anySatisfy({ $0.getActiveLoserRound() != nil }) }) ?? upperBracketRound.loserRounds.first(where: { $0.shouldBeDisplayed }))
if upperBracketRound.loserRounds.allSatisfy({ $0.shouldBeDisplayed == false }) {
_isEditingTournamentSeed = .init(wrappedValue: true)
}
}
var destinations: [LoserRound] {

@ -92,7 +92,7 @@ struct RoundSettingsView: View {
Section {
let roundIndex = tournament.rounds().count
RowButtonView("Ajouter " + RoundRule.roundName(fromRoundIndex: roundIndex)) {
RowButtonView("Ajouter " + RoundRule.roundName(fromRoundIndex: roundIndex), role: .destructive) {
let round = Round(tournament: tournament.id, index: roundIndex, matchFormat: tournament.matchFormat)
let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex)
let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex)
@ -132,7 +132,8 @@ struct RoundSettingsView: View {
Logger.error(error)
}
round.buildLoserBracket()
matches.filter { $0.disabled }.forEach { $0._toggleLoserMatchDisableState(true)
matches.filter { $0.disabled }.forEach {
$0._toggleLoserMatchDisableState(true)
}
}
}
@ -153,6 +154,18 @@ struct RoundSettingsView: View {
}
}
}
Section {
RowButtonView("Synchroniser les noms des matchs") {
let allRoundMatches = tournament.allRoundMatches()
allRoundMatches.forEach({ $0.name = $0.roundTitle() })
do {
try self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: allRoundMatches)
} catch {
Logger.error(error)
}
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {

@ -74,6 +74,7 @@ struct RoundView: View {
let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle())
TipView(bracketTip).tipStyle(tint: .green, asSection: true)
if upperRound.round.hasStarted() == false {
Section {
let leftToPlay = (RoundRule.numberOfMatches(forRoundIndex: upperRound.round.index) - disabledMatchesCount)
LabeledContent {
@ -85,6 +86,7 @@ struct RoundView: View {
Text("\(disabledMatchesCount) match\(disabledMatchesCount.pluralSuffix) désactivé\(disabledMatchesCount.pluralSuffix) automatiquement")
}
}
}
if isEditingTournamentSeed.wrappedValue == false {
//(where: { $0.isDisabled() == false || isEditingTournamentSeed.wrappedValue })

@ -9,12 +9,13 @@ import SwiftUI
struct TournamentFilterView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(NavigationViewModel.self) private var navigation
@Environment(\.dismiss) private var dismiss
@State private var levels: Set<TournamentLevel>
@State private var categories: Set<TournamentCategory>
@State private var ageCategories: Set<FederalTournamentAge>
@State private var selectedClubs: Set<String>
var federalDataViewModel: FederalDataViewModel
@State private var federalDataViewModel: FederalDataViewModel
init(federalDataViewModel: FederalDataViewModel) {
self.federalDataViewModel = federalDataViewModel
@ -27,30 +28,26 @@ struct TournamentFilterView: View {
var body: some View {
NavigationView {
Form {
let clubs : [Club] = dataStore.user.clubsObjects()
if clubs.filter({ $0.code != nil }).isEmpty == false {
Section {
ForEach(clubs.filter({ $0.code != nil })) { club in
LabeledContent {
Button {
if selectedClubs.contains(club.code!) {
selectedClubs.remove(club.code!)
} else {
selectedClubs.insert(club.code!)
}
Picker(selection: $federalDataViewModel.dayDuration) {
Text("aucune").tag(nil as Int?)
Text(1.formatted()).tag(1 as Int?)
Text(2.formatted()).tag(2 as Int?)
Text(3.formatted()).tag(3 as Int?)
} label: {
if selectedClubs.contains(club.code!) {
Image(systemName: "checkmark.circle.fill")
Text("Durée max (en jours)")
}
Picker(selection: $federalDataViewModel.dayPeriod) {
ForEach(DayPeriod.allCases) {
Text($0.localizedDayPeriodLabel()).tag($0)
}
} label: {
Text(club.clubTitle())
}
}
} header: {
Text("Clubs")
Text("En semaine ou week-end")
}
}
Section {
ForEach(TournamentLevel.allCases) { level in
LabeledContent {
@ -74,7 +71,7 @@ struct TournamentFilterView: View {
}
Section {
ForEach(TournamentCategory.allCases) { category in
ForEach([TournamentCategory.men, TournamentCategory.women, TournamentCategory.mix]) { category in
LabeledContent {
Button {
if categories.contains(category) {
@ -88,7 +85,7 @@ struct TournamentFilterView: View {
}
}
} label: {
Text(category.localizedLabel(.title))
Text(category.localizedLabel(.wide))
}
}
} header: {
@ -116,6 +113,58 @@ struct TournamentFilterView: View {
} header: {
Text("Catégories d'âge")
}
if navigation.agendaDestination == .around {
let clubs : [FederalClub] = federalDataViewModel.searchedClubs
if clubs.isEmpty == false {
Section {
ForEach(clubs) { club in
LabeledContent {
Button {
if selectedClubs.contains(club.federalClubCode) {
selectedClubs.remove(club.federalClubCode)
} else {
selectedClubs.insert(club.federalClubCode)
}
} label: {
if selectedClubs.contains(club.federalClubCode) {
Image(systemName: "checkmark.circle.fill")
}
}
} label: {
Text(club.federalClubName)
}
}
} header: {
Text("Clubs")
}
}
} else {
let clubs : [Club] = dataStore.user.clubsObjects().filter({ $0.code != nil })
if clubs.isEmpty == false {
Section {
ForEach(clubs) { club in
LabeledContent {
Button {
if selectedClubs.contains(club.code!) {
selectedClubs.remove(club.code!)
} else {
selectedClubs.insert(club.code!)
}
} label: {
if selectedClubs.contains(club.code!) {
Image(systemName: "checkmark.circle.fill")
}
}
} label: {
Text(club.clubTitle())
}
}
} header: {
Text("Clubs")
}
}
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {

@ -81,6 +81,7 @@ struct EditingTeamView: View {
Text("Confirmation reçue")
Text("L'équipe vous a confirmé votre convocation")
}
} else {
Text("Cette équipe n'a pas été convoquée")
}

@ -14,8 +14,11 @@ struct TeamPickerView: View {
@State private var confirmTeam: TeamRegistration?
@State private var presentTeamPickerView: Bool = false
@State private var searchField: String = ""
@State private var sortOrder: SortOrder = .ascending
var shouldConfirm: Bool = false
var groupStagePosition: Int? = nil
var matchTypeContext: MatchType = .bracket
var luckyLosers: [TeamRegistration] = []
let teamPicked: ((TeamRegistration) -> (Void))
@ -43,40 +46,20 @@ struct TeamPickerView: View {
Text("Même ligne en poule")
}
}
let teams = tournament.selectedSortedTeams()
if luckyLosers.isEmpty == false {
Section {
_teamListView(luckyLosers.sorted(by: \.weight))
} header: {
Text("Repêchage")
}
}
_sectionView(luckyLosers.sorted(by: \.weight, order: sortOrder), title: "Repêchage")
let qualified = tournament.availableQualifiedTeams()
if qualified.isEmpty == false {
Section {
_teamListView(qualified.sorted(by: \.weight))
} header: {
Text("Qualifiées entrants")
}
}
_sectionView(qualified.sorted(by: \.weight, order: sortOrder), title: "Qualifiées entrants")
Section {
_teamListView(teams.filter({ $0.availableForSeedPick() }).sorted(by: \.weight).reversed())
} header: {
Text("Disponible")
}
Section {
_teamListView(teams.filter({ $0.inGroupStage() }).sorted(by: \.groupStagePosition!).reversed())
} header: {
Text("Déjà placée en poule")
}
Section {
_teamListView(teams.filter({ $0.inRound() }).sorted(by: \.bracketPosition!).reversed())
} header: {
Text("Déjà placée dans le tableau")
let teams = tournament.selectedSortedTeams()
if matchTypeContext == .loserBracket {
_sectionView(teams.filter({ $0.inGroupStage() && $0.qualified == false }).sorted(by: \.weight, order: sortOrder), title: "Non qualifié de poules")
}
_sectionView(teams.filter({ $0.availableForSeedPick() }).sorted(by: \.weight, order: sortOrder), title: "Disponible")
_sectionView(teams.filter({ $0.inGroupStage() }).sorted(by: \.weight, order: sortOrder), title: "Déjà placée en poule")
_sectionView(teams.filter({ $0.inRound() }).sorted(by: \.weight, order: sortOrder), title: "Déjà placée dans le tableau")
}
.searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always))
.keyboardType(.alphabet)
@ -84,11 +67,51 @@ struct TeamPickerView: View {
.navigationTitle("Choisir une équipe")
.toolbarBackground(.visible, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
presentTeamPickerView = false
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Section {
Picker(selection: $sortOrder) {
Label("Trier les équipes par poids croissant", systemImage: "chevron.up").tag(SortOrder.ascending)
.labelStyle(.titleAndIcon)
Label("Trier les équipes par poids décroissant", systemImage: "chevron.down").tag(SortOrder.descending)
.labelStyle(.titleAndIcon)
} label: {
Text("Trier les équipes par poids")
}
.pickerStyle(.inline)
} header: {
Text("Trier les équipes par poids")
}
} label: {
HStack {
Text("Poids")
Image(systemName: sortOrder == .ascending ? "chevron.up" : "chevron.down")
}
}
}
}
}
.tint(.master)
}
}
@ViewBuilder
private func _sectionView(_ teams: [TeamRegistration], title: String) -> some View {
if teams.isEmpty == false {
Section {
_teamListView(teams)
} header: {
Text(title)
}
}
}
private func _teamListView(_ teams: [TeamRegistration]) -> some View {
ForEach(teams) { team in
if searchField.isEmpty || team.contains(searchField) {

@ -21,6 +21,17 @@ struct TeamRowView: View {
Text(name).foregroundStyle(.secondary)
}
if let groupStage = team.groupStageObject() {
HStack {
Text(groupStage.groupStageTitle())
if let finalPosition = groupStage.finalPosition(ofTeam: team) {
Text((finalPosition + 1).ordinalFormatted())
}
}
} else if let round = team.initialRound() {
Text(round.roundTitle(.wide))
}
if team.players().isEmpty == false {
ForEach(team.players()) { player in
Text(player.playerLabel())

@ -106,6 +106,7 @@ struct TournamentRankView: View {
}
}
}
.id(calculating)
.alert("Position", isPresented: isEditingTeam) {
if let selectedTeam {
@Bindable var team = selectedTeam
@ -154,6 +155,7 @@ struct TournamentRankView: View {
}
struct TeamRankCellView: View {
@EnvironmentObject var dataStore: DataStore
@Environment(\.editMode) private var editMode
@Environment(Tournament.self) var tournament: Tournament
@State private var isEditingTeam: Bool = false
@ -192,7 +194,17 @@ struct TournamentRankView: View {
.fontWeight(.bold)
Text(key.ordinalFormattedSuffix()).font(.caption)
}
if let index = tournament.indexOf(team: team) {
ZStack {
HStack(spacing: 0.0) {
Text(tournament.teamCount.formatted(.number.sign(strategy: .always())))
.monospacedDigit()
Image(systemName: "arrowtriangle.down.fill")
.imageScale(.small)
}
.opacity(0)
let rankingDifference = index - (key - 1)
if rankingDifference > 0 {
HStack(spacing: 0.0) {
@ -215,6 +227,7 @@ struct TournamentRankView: View {
}
}
}
}
Divider()
@ -295,6 +308,7 @@ struct TournamentRankView: View {
calculating = true
}
self.rankings.removeAll()
let finalRanks = await tournament.finalRanking()
finalRanks.keys.sorted().forEach { rank in
if let rankedTeamIds = finalRanks[rank] {

@ -23,8 +23,16 @@ struct TournamentCellView: View {
var body: some View {
ForEach(tournament.tournaments, id: \.id) { build in
if navigation.agendaDestination == .around, let federalTournament = tournament as? FederalTournament {
NavigationLink {
TournamentSubscriptionView(federalTournament: federalTournament, build: build, user: dataStore.user)
} label: {
_buildView(build, existingTournament: event?.existingBuild(build))
}
} else {
_buildView(build, existingTournament: event?.existingBuild(build))
}
}
}
var teamCount: Int? {
@ -98,7 +106,7 @@ struct TournamentCellView: View {
} else if let teamCount {
Text(teamCount.formatted())
}
} else if let federalTournament = tournament as? FederalTournament {
} else if let federalTournament = tournament as? FederalTournament, navigation.agendaDestination != .around {
Button {
_createOrShow(federalTournament: federalTournament, existingTournament: existingTournament, build: build)
} label: {

@ -22,7 +22,12 @@ struct TournamentInitView: View {
LabeledContent {
Text(tournaments.count.formatted() + " tournoi" + tournaments.count.pluralSuffix)
} label: {
Text("Gestion de l'événement")
Text("Réglages de l'événement")
if let eventName = event.name, eventName.isEmpty == false {
Text(eventName).foregroundStyle(.secondary)
} else {
Text("Aucune description").foregroundStyle(.secondary)
}
}
}
}

@ -21,7 +21,8 @@ struct TournamentRunningView: View {
MatchListView(section: "en cours", matches: tournament.runningMatches(allMatches), hideWhenEmpty: tournament.hasEnded())
// MatchListView(section: "à lancer", matches: tournament.readyMatches(allMatches), isExpanded: false)
// MatchListView(section: "disponible", matches: tournament.availableToStart(allMatches), isExpanded: false)
MatchListView(section: "terminés", matches: tournament.finishedMatches(allMatches), isExpanded: tournament.hasEnded())
let finishedMatches = tournament.finishedMatches(allMatches, limit: tournament.courtCount)
MatchListView(section: "Dernier\(finishedMatches.count.pluralSuffix) match\(finishedMatches.count.pluralSuffix) terminé\(finishedMatches.count.pluralSuffix)", matches: finishedMatches, isExpanded: tournament.hasEnded())
}
}

@ -131,7 +131,7 @@ struct TournamentView: View {
}
NavigationLink(value: Screen.event) {
Text("Gestion de l'événement")
Text("Réglages de l'événement")
}
}
@ -176,7 +176,7 @@ struct TournamentView: View {
}
NavigationLink(value: Screen.event) {
Text("Gestion de l'événement")
Text("Réglages de l'événement")
}
NavigationLink(value: Screen.settings) {
LabelSettings()

Loading…
Cancel
Save