diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 5da9c1a..f613ca7 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -153,6 +153,16 @@ FF8F264F2BAE0B9600650388 /* MatchTypeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8F264E2BAE0B9600650388 /* MatchTypeSelectionView.swift */; }; FF8F26512BAE0BAD00650388 /* MatchFormatPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8F26502BAE0BAD00650388 /* MatchFormatPickerView.swift */; }; FF8F26542BAE1E4400650388 /* TableStructureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8F26532BAE1E4400650388 /* TableStructureView.swift */; }; + FF9267F82BCE78C70080F940 /* CashierView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9267F72BCE78C70080F940 /* CashierView.swift */; }; + FF9267FA2BCE78EC0080F940 /* CashierDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9267F92BCE78EB0080F940 /* CashierDetailView.swift */; }; + FF9267FC2BCE84870080F940 /* PlayerPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9267FB2BCE84870080F940 /* PlayerPayView.swift */; }; + FF9267FF2BCE94830080F940 /* CallSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9267FE2BCE94830080F940 /* CallSettingsView.swift */; }; + FF9268012BCE94920080F940 /* SeedsCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9268002BCE94920080F940 /* SeedsCallingView.swift */; }; + FF9268032BCE94A30080F940 /* GroupStageCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9268022BCE94A30080F940 /* GroupStageCallingView.swift */; }; + FF9268072BCE94D90080F940 /* TournamentCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9268062BCE94D90080F940 /* TournamentCallView.swift */; }; + FF9268092BCEDC2C0080F940 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9268082BCEDC2C0080F940 /* CallView.swift */; }; + FF92680B2BCEE3E10080F940 /* ContactManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF92680A2BCEE3E10080F940 /* ContactManager.swift */; }; + FF92680D2BCEE5EA0080F940 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF92680C2BCEE5EA0080F940 /* NetworkMonitor.swift */; }; FF967CEA2BAEC70100A9A3BD /* GroupStage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF967CE72BAEC70100A9A3BD /* GroupStage.swift */; }; FF967CEC2BAECB9900A9A3BD /* Match.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF967CEB2BAECB9900A9A3BD /* Match.swift */; }; FF967CEE2BAECBD700A9A3BD /* Round.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF967CED2BAECBD700A9A3BD /* Round.swift */; }; @@ -410,6 +420,16 @@ FF8F264E2BAE0B9600650388 /* MatchTypeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchTypeSelectionView.swift; sourceTree = ""; }; FF8F26502BAE0BAD00650388 /* MatchFormatPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchFormatPickerView.swift; sourceTree = ""; }; FF8F26532BAE1E4400650388 /* TableStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableStructureView.swift; sourceTree = ""; }; + FF9267F72BCE78C70080F940 /* CashierView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashierView.swift; sourceTree = ""; }; + FF9267F92BCE78EB0080F940 /* CashierDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashierDetailView.swift; sourceTree = ""; }; + FF9267FB2BCE84870080F940 /* PlayerPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPayView.swift; sourceTree = ""; }; + FF9267FE2BCE94830080F940 /* CallSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettingsView.swift; sourceTree = ""; }; + FF9268002BCE94920080F940 /* SeedsCallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedsCallingView.swift; sourceTree = ""; }; + FF9268022BCE94A30080F940 /* GroupStageCallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStageCallingView.swift; sourceTree = ""; }; + FF9268062BCE94D90080F940 /* TournamentCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentCallView.swift; sourceTree = ""; }; + FF9268082BCEDC2C0080F940 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; + FF92680A2BCEE3E10080F940 /* ContactManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManager.swift; sourceTree = ""; }; + FF92680C2BCEE5EA0080F940 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; FF967CE72BAEC70100A9A3BD /* GroupStage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStage.swift; sourceTree = ""; }; FF967CEB2BAECB9900A9A3BD /* Match.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Match.swift; sourceTree = ""; }; FF967CED2BAECBD700A9A3BD /* Round.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Round.swift; sourceTree = ""; }; @@ -620,6 +640,7 @@ FFCFC00B2BBC39A600B82851 /* Score */, FF967D072BAF3D3000A9A3BD /* Team */, FF089EB92BB011EE00F0AEC7 /* Player */, + FF9267FD2BCE94520080F940 /* Calling */, FFF964512BC2628600EEF017 /* Planning */, FF3F74F72B919F96004CFE0E /* Tournament */, C4A47D882B7BBB5000ADC637 /* Subscription */, @@ -682,6 +703,7 @@ children = ( FF089EB32BB0020000F0AEC7 /* PlayerSexPickerView.swift */, FF089EBA2BB0120700F0AEC7 /* PlayerPopoverView.swift */, + FF9267FB2BCE84870080F940 /* PlayerPayView.swift */, ); path = Components; sourceTree = ""; @@ -802,6 +824,9 @@ FF8F26422BADFE5B00650388 /* TournamentSettingsView.swift */, FF8F26532BAE1E4400650388 /* TableStructureView.swift */, FF0E0B6C2BC254C6005F00A9 /* TournamentScheduleView.swift */, + FF9268062BCE94D90080F940 /* TournamentCallView.swift */, + FF9267F72BCE78C70080F940 /* CashierView.swift */, + FF9267F92BCE78EB0080F940 /* CashierDetailView.swift */, FF8F26522BAE0E4E00650388 /* Components */, ); path = Screen; @@ -918,6 +943,17 @@ path = Components; sourceTree = ""; }; + FF9267FD2BCE94520080F940 /* Calling */ = { + isa = PBXGroup; + children = ( + FF9267FE2BCE94830080F940 /* CallSettingsView.swift */, + FF9268002BCE94920080F940 /* SeedsCallingView.swift */, + FF9268022BCE94A30080F940 /* GroupStageCallingView.swift */, + FF9268082BCEDC2C0080F940 /* CallView.swift */, + ); + path = Calling; + sourceTree = ""; + }; FF967CF92BAEE11500A9A3BD /* GroupStage */ = { isa = PBXGroup; children = ( @@ -1005,6 +1041,8 @@ FF0EC51D2BB16F680056B6D1 /* SwiftParser.swift */, FF1DC55A2BAB80C400FD8220 /* DisplayContext.swift */, FFF8ACD32B92392C008466FA /* SourceFileManager.swift */, + FF92680A2BCEE3E10080F940 /* ContactManager.swift */, + FF92680C2BCEE5EA0080F940 /* NetworkMonitor.swift */, FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */, FF8F26352BAD523300650388 /* PadelRule.swift */, FFA6D7842BB0B795003A31F3 /* FileImportManager.swift */, @@ -1251,6 +1289,7 @@ files = ( C4A47D872B7BA36D00ADC637 /* UserCreationView.swift in Sources */, FF7091662B90F0B000AB08DA /* TabDestination.swift in Sources */, + FF9267F82BCE78C70080F940 /* CashierView.swift in Sources */, FF8F263F2BAD7D5C00650388 /* Event.swift in Sources */, FF089EBF2BB0B14600F0AEC7 /* FileImportView.swift in Sources */, C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */, @@ -1265,10 +1304,13 @@ FFB9C8752BBADDF700A0EF4F /* SeedInterval.swift in Sources */, FFBF065C2BBD2657009D6715 /* GroupStageTeamView.swift in Sources */, FF5DA1932BB9279B00A33061 /* RoundSettingsView.swift in Sources */, + FF9268012BCE94920080F940 /* SeedsCallingView.swift in Sources */, + FF9268092BCEDC2C0080F940 /* CallView.swift in Sources */, FF5D0D742BB41DF8005CB568 /* Color+Extensions.swift in Sources */, C4A47DB12B86375E00ADC637 /* MainUserView.swift in Sources */, FF7091682B90F79F00AB08DA /* TournamentCellView.swift in Sources */, FF6EC9042B9479F500EA7F5A /* Sequence+Extensions.swift in Sources */, + FF9267FA2BCE78EC0080F940 /* CashierDetailView.swift in Sources */, C4A47DB32B86387500ADC637 /* AccountView.swift in Sources */, FF1CBC1D2BB53DC10036DAAB /* Calendar+Extensions.swift in Sources */, FF967CF22BAECC0B00A9A3BD /* TeamScore.swift in Sources */, @@ -1289,8 +1331,10 @@ FFC1E10A2BAC2A77008D6F59 /* NetworkFederalService.swift in Sources */, FFCFC00E2BBC3D4600B82851 /* PointSelectionView.swift in Sources */, FF089EB62BB00A3800F0AEC7 /* TeamRowView.swift in Sources */, + FF92680B2BCEE3E10080F940 /* ContactManager.swift in Sources */, FFCFC00C2BBC3D1E00B82851 /* EditScoreView.swift in Sources */, FF7091622B90F04300AB08DA /* TournamentOrganizerView.swift in Sources */, + FF92680D2BCEE5EA0080F940 /* NetworkMonitor.swift in Sources */, FF967CF62BAED51600A9A3BD /* TournamentRunningView.swift in Sources */, FF8F264D2BAE0B4100650388 /* TournamentDatePickerView.swift in Sources */, FF967D042BAEF1C300A9A3BD /* MatchRowView.swift in Sources */, @@ -1328,12 +1372,14 @@ FFCFC0162BBC5A4C00B82851 /* SetInputView.swift in Sources */, FF5D0D892BB4935C005CB568 /* ClubRowView.swift in Sources */, FF1DC5512BAB351300FD8220 /* ClubDetailView.swift in Sources */, + FF9268032BCE94A30080F940 /* GroupStageCallingView.swift in Sources */, FFFCDE0E2BCC833600317DEF /* LoserRoundScheduleEditorView.swift in Sources */, C4A47D632B6D3D6500ADC637 /* Club.swift in Sources */, FF6EC90B2B947AC000EA7F5A /* Array+Extensions.swift in Sources */, FF59FFB92B90EFD70061EFF9 /* ToolboxView.swift in Sources */, FFF8ACD92B923F3C008466FA /* String+Extensions.swift in Sources */, FFC2DCB22BBE75D40046DB9F /* LoserBracketView.swift in Sources */, + FF9267FC2BCE84870080F940 /* PlayerPayView.swift in Sources */, FFA6D7852BB0B795003A31F3 /* FileImportManager.swift in Sources */, FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */, FFF8ACCD2B92367B008466FA /* FederalPlayer.swift in Sources */, @@ -1351,6 +1397,7 @@ FF967D0B2BAF3D4C00A9A3BD /* TeamPickerView.swift in Sources */, FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */, FF1DC55B2BAB80C400FD8220 /* DisplayContext.swift in Sources */, + FF9268072BCE94D90080F940 /* TournamentCallView.swift in Sources */, C425D4032B6D249D002A7B48 /* ContentView.swift in Sources */, FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */, FF967CFC2BAEE52E00A9A3BD /* GroupStagesView.swift in Sources */, @@ -1399,6 +1446,7 @@ C4A47DA62B83948E00ADC637 /* LoginView.swift in Sources */, FF967CF82BAEDF0000A9A3BD /* Labels.swift in Sources */, FF089EB42BB0020000F0AEC7 /* PlayerSexPickerView.swift in Sources */, + FF9267FF2BCE94830080F940 /* CallSettingsView.swift in Sources */, FF5D0D852BB48997005CB568 /* RankCalculatorView.swift in Sources */, FF70916A2B90F95E00AB08DA /* DateBoxView.swift in Sources */, FF5D0D722BB3EFA5005CB568 /* LearnMoreSheetView.swift in Sources */, diff --git a/PadelClub/Data/PlayerRegistration.swift b/PadelClub/Data/PlayerRegistration.swift index 590adaa..f5ffa14 100644 --- a/PadelClub/Data/PlayerRegistration.swift +++ b/PadelClub/Data/PlayerRegistration.swift @@ -18,7 +18,7 @@ class PlayerRegistration: ModelObject, Storable { var lastName: String var licenceId: String? var rank: Int? - var registrationType: Int? + var registrationType: PaymentType? var registrationDate: Date? var sex: Int @@ -37,7 +37,7 @@ class PlayerRegistration: ModelObject, Storable { var hasArrived: Bool = false - internal init(teamRegistration: String? = nil, firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, registrationType: Int? = nil, registrationDate: Date? = nil, sex: Int, source: PlayerDataSource? = nil) { + internal init(teamRegistration: String? = nil, firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, registrationType: PaymentType? = nil, registrationDate: Date? = nil, sex: Int, source: PlayerDataSource? = nil) { self.teamRegistration = teamRegistration self.firstName = firstName self.lastName = lastName @@ -117,15 +117,6 @@ class PlayerRegistration: ModelObject, Storable { func hasPaid() -> Bool { registrationType != nil } - - var paymentType: PaymentType { - get { - PaymentType(rawValue: registrationType ?? -1) ?? .notPaid - } - set { - registrationType = newValue.rawValue - } - } func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String { switch displayStyle { @@ -233,6 +224,16 @@ class PlayerRegistration: ModelObject, Storable { sex == 1 } + func validateLicenceId(_ year: Int) { + if let currentLicenceId = licenceId { + if currentLicenceId.trimmed.hasSuffix("(\(year-1))") { + self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)") + } else if let computedLicense = currentLicenceId.strippedLicense { + self.licenceId = computedLicense + " (\(year))" + } + } + } + enum CodingKeys: String, CodingKey { case _id = "id" case _teamRegistration = "teamRegistration" @@ -262,21 +263,27 @@ class PlayerRegistration: ModelObject, Storable { case beachPadel } - enum PaymentType: Int, CaseIterable, Identifiable { + enum PaymentType: Int, CaseIterable, Identifiable, Codable { + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + var id: Self { self } - case notPaid = -1 + case cash = 0 case lydia = 1 case gift = 2 case check = 3 case paylib = 4 + case bankTransfer = 5 + case clubHouse = 6 + case creditCard = 7 - var localizedLabel: String { + func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { switch self { - case .notPaid: - return "Non réglé" case .check: return "Chèque" case .cash: @@ -285,6 +292,12 @@ class PlayerRegistration: ModelObject, Storable { return "Lydia" case .paylib: return "Paylib" + case .bankTransfer: + return "Virement" + case .clubHouse: + return "Clubhouse" + case .creditCard: + return "CB" case .gift: return "Offert" } diff --git a/PadelClub/Data/Round.swift b/PadelClub/Data/Round.swift index cd0edcb..37a4ab2 100644 --- a/PadelClub/Data/Round.swift +++ b/PadelClub/Data/Round.swift @@ -99,6 +99,22 @@ class Round: ModelObject, Storable { }) } + func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] { + return Store.main.filter(isIncluded: { + $0.tournament == tournament && $0.bracketPosition != nil + }).filter({ + ($0.bracketPosition! / 2) == matchIndex + }) + } + + func seeds() -> [TeamRegistration] { + return Store.main.filter(isIncluded: { + $0.tournament == tournament && $0.bracketPosition != nil + }).filter({ + ($0.bracketPosition! / 2) >= RoundRule.matchIndex(fromRoundIndex: index) && ($0.bracketPosition! / 2) < RoundRule.matchIndex(fromRoundIndex: index) + RoundRule.numberOfMatches(forRoundIndex: index) + }) + } + func losers() -> [TeamRegistration] { _matches().compactMap { $0.losingTeamId }.compactMap { Store.main.findById($0) } } diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index 4171037..0f42cb0 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -70,6 +70,15 @@ class TeamRegistration: ModelObject, Storable { func confirmed() -> Bool { confirmationDate != nil } + + func getPhoneNumbers() -> [String] { + return players().compactMap { $0.phoneNumber }.filter({ $0.isMobileNumber() }) + } + + func getMail() -> [String] { + let mails = players().compactMap({ $0.email }) + return mails + } func isImported() -> Bool { unsortedPlayers().allSatisfy({ $0.isImported() }) diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index c47641b..67690a4 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -155,7 +155,14 @@ class Tournament : ModelObject, Storable { return .initial } - + func seededTeams() -> [TeamRegistration] { + selectedSortedTeams().filter({ $0.bracketPosition != nil && $0.groupStagePosition == nil }) + } + + func groupStageTeams() -> [TeamRegistration] { + selectedSortedTeams().filter({ $0.bracketPosition == nil && $0.groupStagePosition != nil }) + } + func seeds() -> [TeamRegistration] { let seeds = max(teamCount - groupStageCount * teamsPerGroupStage, 0) return Array(selectedSortedTeams().prefix(seeds)) @@ -416,6 +423,10 @@ class Tournament : ModelObject, Storable { unsortedTeams().flatMap { $0.unsortedPlayers() } } + func selectedPlayers() -> [PlayerRegistration] { + selectedSortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.weight) + } + func players() -> [PlayerRegistration] { unsortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.weight) } @@ -609,11 +620,56 @@ class Tournament : ModelObject, Storable { //return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified } + var entryFeeMessage: String { + if let entryFee { + return "Inscription: " + entryFee.formatted(.currency(code: "EUR")) + "." + } else { + return "Inscription: gratuite." + } + } + + func umpireMail() -> [String]? { + if let mail = UserDefaults.standard.string(forKey: "umpireMail"), mail.isEmpty == false { + return [mail] + } else { + return nil + } + +// if let umpireMail = federalTournament?.courrielEngagement { +// return [umpireMail] +// } else { +// } + } + + func earnings() -> Double { + if let entryFee { + return Double(selectedPlayers().filter { $0.hasPaid() }.count) * entryFee + } else { + return 0.0 + } + } + + func paidCompletion() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasPaid() }.count) / Double(selectedPlayers.count) + } + + func cashierStatus() -> String { + //todo + return "todo" + } + func scheduleStatus() -> String { //todo return "todo" } + func callStatus() -> String { + //todo + return "todo" + } + func bracketStatus() -> String { if let round = getActiveRound() { return [round.roundTitle(), round.roundStatus()].joined(separator: " ") diff --git a/PadelClub/Extensions/Date+Extensions.swift b/PadelClub/Extensions/Date+Extensions.swift index 67e902f..835302e 100644 --- a/PadelClub/Extensions/Date+Extensions.swift +++ b/PadelClub/Extensions/Date+Extensions.swift @@ -36,6 +36,10 @@ enum TimeOfDay { extension Date { + func localizedDate() -> String { + self.formatted(.dateTime.weekday().day().month()) + " à " + self.formatted(.dateTime.hour().minute()) + } + var monthYearFormatted: String { formatted(.dateTime.month(.wide).year(.defaultDigits)) } diff --git a/PadelClub/Extensions/String+Extensions.swift b/PadelClub/Extensions/String+Extensions.swift index 9eca94f..1b233af 100644 --- a/PadelClub/Extensions/String+Extensions.swift +++ b/PadelClub/Extensions/String+Extensions.swift @@ -59,6 +59,15 @@ extension String { } extension String { + enum RegexStatic { + static let mobileNumber = /^0[6-7]/ + //static let mobileNumber = /^(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})$/ + } + + func isMobileNumber() -> Bool { + firstMatch(of: RegexStatic.mobileNumber) != nil + } + var computedLicense: String { if let licenseKey { return self + licenseKey diff --git a/PadelClub/Manager/ContactManager.swift b/PadelClub/Manager/ContactManager.swift new file mode 100644 index 0000000..d139a89 --- /dev/null +++ b/PadelClub/Manager/ContactManager.swift @@ -0,0 +1,195 @@ +// +// ContactManager.swift +// Padel Tournament +// +// Created by Razmig Sarkissian on 19/09/2023. +// + +import Foundation +import SwiftUI +import MessageUI + +enum ContactManagerError: LocalizedError { + case mailFailed + case mailNotSent //no network no error + case messageFailed + case messageNotSent //no network no error +} + +enum ContactType: Identifiable { + case mail(date: Date?, recipients: [String]?, bccRecipients: [String]?, body: String?, subject: String?, tournamentBuild: TournamentBuild?) + case message(date: Date?, recipients: [String]?, body: String?, tournamentBuild: TournamentBuild?) + + var id: Int { + switch self { + case .message: return 0 + case .mail: return 1 + } + } +} + +extension ContactType { + static let defaultCustomMessage = "Il est conseillé de vous présenter 10 minutes avant de jouer.\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire." + static let defaultSignature = "" + + static func callingGroupStageCustomMessage(tournament: Tournament?, startDate: Date?, roundLabel: String) -> String { + let tournamentCustomMessage = UserDefaults.standard.string(forKey: "customMessage") ?? defaultCustomMessage + let clubName = tournament?.clubName ?? "" + + var text = tournamentCustomMessage + let date = startDate ?? tournament?.startDate ?? Date() + + if let tournament { + text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle()) + } + + text = text.replacingOccurrences(of: "#club", with: clubName) + text = text.replacingOccurrences(of: "#round", with: roundLabel) + text = text.replacingOccurrences(of: "#jour", with: "\(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide)))") + text = text.replacingOccurrences(of: "#horaire", with: "\(date.formatted(Date.FormatStyle().hour().minute()))") + + let signature = UserDefaults.standard.string(forKey: "mySelf") ?? defaultSignature + + text = text.replacingOccurrences(of: "#signature", with: signature) + return text + } + + static func callingGroupStageMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?) -> String { + + let useFullCustomMessage = UserDefaults.standard.bool(forKey: "useFullCustomMessage") + + if useFullCustomMessage { + return callingGroupStageCustomMessage(tournament: tournament, startDate: startDate, roundLabel: roundLabel) + } + + let date = startDate ?? tournament?.startDate ?? Date() + + let clubName = tournament?.clubName ?? "" + let message = UserDefaults.standard.string(forKey: "customMessage") ?? defaultCustomMessage + let signature = UserDefaults.standard.string(forKey: "mySelf") ?? defaultSignature + + let localizedCalled = "convoqué" + (tournament?.tournamentCategory == .women ? "e" : "") + "s" + + var formatMessage: String? { + UserDefaults.standard.bool(forKey: "displayFormat") ? matchFormat?.computedLongLabel.appending(".") : nil + } + + var entryFeeMessage: String? { + UserDefaults.standard.bool(forKey: "displayEntryFee") ? tournament?.entryFeeMessage : nil + } + + var computedMessage: String { + [formatMessage, entryFeeMessage, message].compacted().map { $0.trimmed }.joined(separator: "\n") + } + + if let tournament { + return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle()) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)" + } else { + return "Bonjour,\n\nVous êtes \(localizedCalled) \(roundLabel) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire !\n\n\(signature)" + } + } +} + + +struct MessageComposeView: UIViewControllerRepresentable { + typealias Completion = (_ result: MessageComposeResult) -> Void + + static var canSendText: Bool { MFMessageComposeViewController.canSendText() } + + let recipients: [String]? + let body: String? + let completion: Completion? + + func makeUIViewController(context: Context) -> UIViewController { + guard Self.canSendText else { + let errorView = ContentUnavailableView("Aucun compte de messagerie", systemImage: "xmark", description: Text("Aucun compte de messagerie n'est configuré sur cet appareil.")) + return UIHostingController(rootView: errorView) + } + + let controller = MFMessageComposeViewController() + controller.messageComposeDelegate = context.coordinator + controller.recipients = recipients + controller.body = body + + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(completion: self.completion) + } + + class Coordinator: NSObject, MFMessageComposeViewControllerDelegate { + private let completion: Completion? + + public init(completion: Completion?) { + self.completion = completion + } + + public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) { + controller.dismiss(animated: true, completion: { + self.completion?(result) + }) + } + } +} + +struct MailComposeView: UIViewControllerRepresentable { + typealias Completion = (_ result: MFMailComposeResult) -> Void + + static var canSendMail: Bool { + if let mailURL = URL(string: "mailto:?to=jap@padelclub.com") { + let mailConfigured = UIApplication.shared.canOpenURL(mailURL) + return mailConfigured && MFMailComposeViewController.canSendMail() + } else { + return MFMailComposeViewController.canSendMail() + } + } + + let recipients: [String]? + let bccRecipients: [String]? + let body: String? + let subject: String? + let completion: Completion? + + func makeUIViewController(context: Context) -> UIViewController { + guard Self.canSendMail else { + let errorView = ContentUnavailableView("Aucun compte mail", systemImage: "xmark", description: Text("Aucun compte mail n'est configuré sur cet appareil.")) + return UIHostingController(rootView: errorView) + } + + let controller = MFMailComposeViewController() + controller.mailComposeDelegate = context.coordinator + controller.setToRecipients(recipients) + controller.setBccRecipients(bccRecipients) + if let body { + controller.setMessageBody(body, isHTML: false) + } + if let subject { + controller.setSubject(subject) + } + + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(completion: self.completion) + } + + class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + private let completion: Completion? + + public init(completion: Completion?) { + self.completion = completion + } + + public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + controller.dismiss(animated: true, completion: { + self.completion?(result) + }) + } + } +} diff --git a/PadelClub/Manager/NetworkMonitor.swift b/PadelClub/Manager/NetworkMonitor.swift new file mode 100644 index 0000000..17a28df --- /dev/null +++ b/PadelClub/Manager/NetworkMonitor.swift @@ -0,0 +1,28 @@ +// +// NetworkMonitor.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import Foundation +import Network + +class NetworkMonitor: ObservableObject { + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "Monitor") + @Published private(set) var connected: Bool = false + + func checkConnection() { + monitor.pathUpdateHandler = { path in + DispatchQueue.main.async { + if path.status == .satisfied { + self.connected = true + } else { + self.connected = false + } + } + } + monitor.start(queue: queue) + } +} diff --git a/PadelClub/PadelClubApp.swift b/PadelClub/PadelClubApp.swift index fae6ecb..c899697 100644 --- a/PadelClub/PadelClubApp.swift +++ b/PadelClub/PadelClubApp.swift @@ -13,10 +13,12 @@ import TipKit struct PadelClubApp: App { let persistenceController = PersistenceController.shared @State private var navigationViewModel = NavigationViewModel() - + @StateObject var networkMonitor: NetworkMonitor = NetworkMonitor() + var body: some Scene { WindowGroup { MainView() + .environmentObject(networkMonitor) .environment(navigationViewModel) .accentColor(.master) .onAppear { diff --git a/PadelClub/Views/Calling/CallSettingsView.swift b/PadelClub/Views/Calling/CallSettingsView.swift new file mode 100644 index 0000000..f794e23 --- /dev/null +++ b/PadelClub/Views/Calling/CallSettingsView.swift @@ -0,0 +1,55 @@ +// +// CallSettingsView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import SwiftUI + +struct CallSettingsView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament: Tournament + + var body: some View { + List { + + Section { + NavigationLink { + } label: { + Text("Modifier le message de convocation") + } + } + + Section { + RowButtonView("Annuler toutes les convocations") { + let teams = tournament.unsortedTeams() + teams.forEach { team in + team.callDate = nil + } + try? dataStore.teamRegistrations.addOrUpdate(contentOfs: teams) + } + } + + Section { + RowButtonView("Envoyer un message à tout le monde") { + + } + } + + Section { + RowButtonView("Tout le monde a été convoqué") { + let teams = tournament.unsortedTeams() + teams.forEach { team in + team.callDate = Date() + } + try? dataStore.teamRegistrations.addOrUpdate(contentOfs: teams) + } + } + } + } +} + +#Preview { + CallSettingsView() +} diff --git a/PadelClub/Views/Calling/CallView.swift b/PadelClub/Views/Calling/CallView.swift new file mode 100644 index 0000000..4abd713 --- /dev/null +++ b/PadelClub/Views/Calling/CallView.swift @@ -0,0 +1,159 @@ +// +// CallView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import SwiftUI + +struct CallView: View { + + struct CallStatusView: View { + let count: Int + let total: Int + let startDate: Date? + + var body: some View { + VStack(spacing: 0) { + HStack { + if let startDate { + Text(startDate.formatted(.dateTime.hour().minute())) + } else { + Text("Aucun horaire") + } + Spacer() + Text(count.formatted() + "/" + total.formatted()) + } + .font(.largeTitle) + HStack { + if let startDate { + Text(startDate.formatted(.dateTime.weekday().day(.twoDigits).month().year())) + } + Spacer() + Text("paires convoquées") + } + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + struct TeamView: View { + let team: TeamRegistration + + var body: some View { + TeamRowView(team: team, displayCallDate: true) + } + } + + @EnvironmentObject var dataStore: DataStore + @EnvironmentObject var networkMonitor: NetworkMonitor + @Environment(Tournament.self) var tournament: Tournament + + var teams: [TeamRegistration] + let callDate: Date + let matchFormat: MatchFormat + let roundLabel: String + + @State private var contactType: ContactType? = nil + @State private var sentError: ContactManagerError? = nil + + var messageSentFailed: Binding { + Binding { + sentError != nil + } set: { newValue in + if newValue == false { + sentError = nil + } + } + } + + private func _called(_ success: Bool) { + if success { + teams.forEach { team in + team.callDate = callDate + } + try? dataStore.teamRegistrations.addOrUpdate(contentOfs: teams) + } + } + + var finalMessage: String { + ContactType.callingGroupStageMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat) + } + + var body: some View { + let callWord = teams.allSatisfy({ $0.called() }) ? "Reconvoquer" : "Convoquer" + HStack { + if teams.count == 1 { + Text(callWord + " cette paire par") + } else { + Text(callWord + " ces \(teams.count) paires par") + } + Button { + contactType = .message(date: callDate, recipients: teams.flatMap { $0.getPhoneNumbers() }, body: finalMessage, tournamentBuild: nil) + } label: { + Text("sms") + .underline() + } + Text("ou") + Button { + contactType = .mail(date: callDate, recipients: tournament.umpireMail(), bccRecipients: teams.flatMap { $0.getMail() }, body: finalMessage, subject: tournament.tournamentTitle(), tournamentBuild: nil) + } label: { + Text("mail") + .underline() + } + } + .font(.subheadline) + .buttonStyle(.borderless) + .alert("Un problème est survenu", isPresented: messageSentFailed) { + Button("OK") { + } + } message: { + let message = [networkMonitor.connected == false ? "L'appareil n'est pas connecté à internet." as String? : nil, sentError == .mailNotSent ? "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." as String? : nil, (sentError == .messageFailed || sentError == .messageNotSent) ? "Le SMS n'a pas été envoyé" as String? : nil, sentError == .mailFailed ? "Le mail n'a pas été envoyé" as String? : nil].compacted().joined(separator: "\n") + Text(message) + } + .sheet(item: $contactType) { contactType in + switch contactType { + case .message(_, let recipients, let body, _): + MessageComposeView(recipients: recipients, body: body) { result in + switch result { + case .cancelled: + _called(true) + break + case .failed: + self.sentError = .messageFailed + case .sent: + if networkMonitor.connected == false { + self.sentError = .messageNotSent + } else { + _called(true) + } + @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 + _called(true) + case .failed: + self.contactType = nil + self.sentError = .mailFailed + case .sent: + if networkMonitor.connected == false { + self.contactType = nil + self.sentError = .mailNotSent + } else { + _called(true) + } + @unknown default: + break + } + } + } + } + } +} diff --git a/PadelClub/Views/Calling/GroupStageCallingView.swift b/PadelClub/Views/Calling/GroupStageCallingView.swift new file mode 100644 index 0000000..c401f3e --- /dev/null +++ b/PadelClub/Views/Calling/GroupStageCallingView.swift @@ -0,0 +1,91 @@ +// +// GroupStageCallingView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import SwiftUI + +struct GroupStageCallingView: View { + @Environment(Tournament.self) var tournament: Tournament + @State private var displayByTeam: Bool = false + + var body: some View { + let groupStages = tournament.groupStages() + List { + _sameTimeGroupStageView(groupStages: groupStages) + + ForEach(groupStages) { groupStage in + + let seeds = groupStage.teams() + let callSeeds = seeds.filter({ $0.callDate != nil }) + + if seeds.isEmpty == false { + Section { + NavigationLink { + _groupStageView(groupStage: groupStage) + .environment(tournament) + } label: { + CallView.CallStatusView(count: callSeeds.count, total: seeds.count, startDate: groupStage.startDate) + } + } header: { + Text(groupStage.groupStageTitle()) + } footer: { + if let startDate = groupStage.startDate { + CallView(teams: seeds, callDate: startDate, matchFormat: groupStage.matchFormat, roundLabel: "poule") + } + } + } + } + } + .headerProminence(.increased) + } + + @ViewBuilder + func _sameTimeGroupStageView(groupStages: [GroupStage]) -> some View { + let times = Dictionary(grouping: groupStages) { groupStage in + groupStage.startDate + } + let keys = times.keys.compactMap { $0 }.sorted() + ForEach(keys, id: \.self) { key in + if let _groupStages = times[key], _groupStages.count > 1 { + let teams = _groupStages.flatMap { $0.teams() } + Section { + CallView.CallStatusView(count: teams.filter({ $0.callDate != nil }).count, total: teams.count, startDate: key) + } header: { + Text(groupStages.map { $0.groupStageTitle() }.joined(separator: ", ")) + } footer: { + CallView(teams: teams, callDate: key, matchFormat: tournament.groupStageMatchFormat, roundLabel: "poule") + } + } + } + } + + @ViewBuilder + private func _groupStageView(groupStage: GroupStage) -> some View { + let teams = groupStage.teams() + List { + if let startDate = groupStage.startDate { + ForEach(teams) { team in + Section { + CallView.TeamView(team: team) + } header: { + Text(startDate.localizedDate()) + } footer: { + CallView(teams: [team], callDate: startDate, matchFormat: groupStage.matchFormat, roundLabel: "poule") + } + } + + } + } + .headerProminence(.increased) + .navigationTitle(groupStage.groupStageTitle()) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } +} + +#Preview { + GroupStageCallingView() +} diff --git a/PadelClub/Views/Calling/SeedsCallingView.swift b/PadelClub/Views/Calling/SeedsCallingView.swift new file mode 100644 index 0000000..e41e049 --- /dev/null +++ b/PadelClub/Views/Calling/SeedsCallingView.swift @@ -0,0 +1,105 @@ +// +// SeedsCallingView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import SwiftUI + +struct SeedsCallingView: View { + @Environment(Tournament.self) var tournament: Tournament + @State private var displayByMatch: Bool = false + + var body: some View { + List { + ForEach(tournament.rounds()) { round in + let seeds = round.seeds() + let callSeeds = seeds.filter({ $0.callDate != nil }) + if seeds.isEmpty == false { + Section { + NavigationLink { + _roundView(round: round) + .environment(tournament) + } label: { + CallView.CallStatusView(count: callSeeds.count, total: seeds.count, startDate: round.playedMatches().first?.startDate) + } + } header: { + Text(round.roundTitle()) + } + } + } + } + .headerProminence(.increased) + } + + @ViewBuilder + private func _roundView(round: Round) -> some View { + let roundMatches = round.playedMatches() + let times = Dictionary(grouping: roundMatches) { match in + match.startDate + } + let keys = times.keys.compactMap { $0 }.sorted() + List { + if displayByMatch == false { + ForEach(keys, id: \.self) { time in + if let matches = times[time] { + let teams = matches.flatMap { round.seeds(inMatchIndex: $0.index) } + Section { + ForEach(teams) { team in + TeamRowView(team: team) + } + } header: { + HStack { + Text(time.localizedDate()) + Spacer() + Text(teams.count.formatted() + " paire" + teams.count.pluralSuffix) + } + } footer: { + CallView(teams: teams, callDate: time, matchFormat: round.matchFormat, roundLabel: round.roundTitle()) + } + } + } + } else { + ForEach(roundMatches) { match in + let teams = round.seeds(inMatchIndex: match.index) + Section { + ForEach(teams) { team in + CallView.TeamView(team: team) + } + } header: { + HStack { + if let startDate = match.startDate { + Text(startDate.localizedDate()) + } else { + Text("Aucun horaire") + } + Spacer() + Text(match.matchTitle()) + } + } footer: { + if let startDate = match.startDate { + CallView(teams: teams, callDate: startDate, matchFormat: match.matchFormat, roundLabel: round.roundTitle()) + } + } + } + } + } + .headerProminence(.increased) + .navigationTitle(round.roundTitle()) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(displayByMatch ? "par horaire" : "par match") { + displayByMatch.toggle() + } + .buttonBorderShape(.capsule) + } + } + } +} + +#Preview { + SeedsCallingView() +} diff --git a/PadelClub/Views/Player/Components/PlayerPayView.swift b/PadelClub/Views/Player/Components/PlayerPayView.swift new file mode 100644 index 0000000..721dcef --- /dev/null +++ b/PadelClub/Views/Player/Components/PlayerPayView.swift @@ -0,0 +1,32 @@ +// +// PlayerPayView.swift +// Padel Tournament +// +// Created by Razmig Sarkissian on 22/11/2023. +// + +import SwiftUI + +struct PlayerPayView: View { + @EnvironmentObject var dataStore: DataStore + @Bindable var player: PlayerRegistration + + var body: some View { + Picker(selection: $player.registrationType) { + Text("Non réglé").tag(nil as PlayerRegistration.PaymentType?) + ForEach(PlayerRegistration.PaymentType.allCases) { type in + Text(type.localizedLabel()).tag(type as PlayerRegistration.PaymentType?) + } + } label: { + } + .pickerStyle(.menu) + .fixedSize() + .onChange(of: player.registrationType) { + _save() + } + } + + private func _save() { + try? dataStore.playerRegistrations.addOrUpdate(instance: player) + } +} diff --git a/PadelClub/Views/Player/Components/PlayerPopoverView.swift b/PadelClub/Views/Player/Components/PlayerPopoverView.swift index 8be49b6..21fa9fa 100644 --- a/PadelClub/Views/Player/Components/PlayerPopoverView.swift +++ b/PadelClub/Views/Player/Components/PlayerPopoverView.swift @@ -174,6 +174,7 @@ struct PlayerPopoverView: View { rank = nil firstNameIsFocused = true } + .disabled(isPlayerValid() == false) } } .onAppear { @@ -191,8 +192,9 @@ struct PlayerPopoverView: View { } .clipShape(Capsule()) .buttonStyle(.bordered) - + .disabled(isPlayerValid() == false) } + ToolbarItem(placement: .cancellationAction) { Button("Annuler", role : .cancel) { dismiss() @@ -229,20 +231,23 @@ struct PlayerPopoverView: View { } } } - - func createManualPlayer() { + + func isPlayerValid() -> Bool { guard (lastName.isEmpty == false && requiredField.contains(.lastName)) || requiredField.contains(.lastName) == false else { - return + return false } guard (license.isEmpty == false && license.isLicenseNumber && requiredField.contains(.license)) || requiredField.contains(.license) == false else { - return + return false } guard (firstName.isEmpty == false && requiredField.contains(.firstName)) || requiredField.contains(.firstName) == false else { - return + return false } - + return true + } + + func createManualPlayer() { let playerRegistration = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: license.trimmed.isEmpty ? nil : license, rank: rank, sex: sex) self.creationCompletionHandler(playerRegistration) } diff --git a/PadelClub/Views/Team/TeamRowView.swift b/PadelClub/Views/Team/TeamRowView.swift index 4ae5b05..1b02d36 100644 --- a/PadelClub/Views/Team/TeamRowView.swift +++ b/PadelClub/Views/Team/TeamRowView.swift @@ -10,6 +10,7 @@ import SwiftUI struct TeamRowView: View { var team: TeamRegistration var teamPosition: TeamPosition? = nil + var displayCallDate: Bool = false var body: some View { LabeledContent { @@ -30,6 +31,12 @@ struct TeamRowView: View { } } label: { Text(team.teamLabel(.short)) + if let callDate = team.callDate { + Text("Déjà convoquée \(callDate.localizedDate())") + .foregroundStyle(.red) + .italic() + .font(.caption) + } } } } diff --git a/PadelClub/Views/Tournament/Screen/CashierDetailView.swift b/PadelClub/Views/Tournament/Screen/CashierDetailView.swift new file mode 100644 index 0000000..59cf60d --- /dev/null +++ b/PadelClub/Views/Tournament/Screen/CashierDetailView.swift @@ -0,0 +1,41 @@ +// +// CashierDetailView.swift +// Padel Tournament +// +// Created by Razmig Sarkissian on 31/03/2024. +// + +import SwiftUI + +struct CashierDetailView: View { + var tournaments : [Tournament] + + var body: some View { + List { + ForEach(tournaments) { tournament in + _tournamentCashierDetailView(tournament) + } + } + .headerProminence(.increased) + .navigationTitle("Résumé") + } + + private func _tournamentCashierDetailView(_ tournament: Tournament) -> some View { + Section { + ForEach(PlayerRegistration.PaymentType.allCases) { type in + let count = tournament.selectedPlayers().filter({ $0.registrationType == type }).count + LabeledContent { + if let entryFee = tournament.entryFee { + let sum = Double(count) * entryFee + Text(sum.formatted(.currency(code: "EUR"))) + } + } label: { + Text(type.localizedLabel()) + Text(count.formatted()) + } + } + } header: { + Text(tournament.tournamentTitle()) + } + } +} diff --git a/PadelClub/Views/Tournament/Screen/CashierView.swift b/PadelClub/Views/Tournament/Screen/CashierView.swift new file mode 100644 index 0000000..bec5b24 --- /dev/null +++ b/PadelClub/Views/Tournament/Screen/CashierView.swift @@ -0,0 +1,539 @@ +// +// CashierView.swift +// Padel Tournament +// +// Created by Razmig Sarkissian on 04/03/2023. +// + +import SwiftUI +import Combine + +struct CashierView: View { + @EnvironmentObject var dataStore: DataStore + var tournaments : [Tournament] + @Environment(\.dismiss) private var dismiss + @State var licenseCheck: Bool? + @State private var sortOption: SortOption = .callDate + @State private var filterOption: FilterOption = .all + @State private var sortOrder: SortOrder = .ascending + @State private var searchText = "" + @State private var licenseToEdit = "" + @State private var editPlayer: PlayerRegistration? + + let licenseMode: Bool + + init(event: Event, licenseCheck: Bool? = nil, sortOption: SortOption = .callDate) { + _licenseCheck = State(wrappedValue: licenseCheck) + _sortOption = State(wrappedValue: sortOption) + licenseMode = licenseCheck != nil + self.tournaments = event.tournaments + } + + init(tournament: Tournament, licenseCheck: Bool? = nil, sortOption: SortOption = .callDate) { + _licenseCheck = State(wrappedValue: licenseCheck) + _sortOption = State(wrappedValue: sortOption) + licenseMode = licenseCheck != nil + self.tournaments = [tournament] + } + + + func somePlayerToEdit() -> Binding { + Binding { + editPlayer != nil + } set: { _ in + } + } + + enum SortOption: Int, Identifiable, CaseIterable { + case round + case team + case alphabeticalLastName + case alphabeticalFirstName + case rank + case age + case callDate + + var id: Int { self.rawValue } + func localizedLabel() -> String { + switch self { + case .round, .callDate: + return "Convocation" + case .team: + return "Équipe" + case .alphabeticalLastName: + return "Nom" + case .alphabeticalFirstName: + return "Prénom" + case .rank: + return "Rang" + case .age: + return "Âge" + } + } + } + + enum FilterOption: Int, Identifiable, CaseIterable { + case all + case didPay + case didNotPay + + var id: Int { self.rawValue } + + func localizedLabel() -> String { + switch self { + case .all: + return "Tous" + case .didPay: + return "Réglé" + case .didNotPay: + return "Non réglé" + } + } + + func shouldDisplayPlayer(_ player: PlayerRegistration) -> Bool { + switch self { + case .all: + return true + case .didPay: + return player.hasPaid() + case .didNotPay: + return player.hasPaid() == false + + } + } + } + + var orderedPlayers: [PlayerRegistration] { + if sortOrder == .ascending { + return sortedPlayers + } else { + return sortedPlayers.reversed() + } + } + + var orderedTeams: [TeamRegistration] { + if sortOrder == .ascending { + return sortedTeams + } else { + return sortedTeams.reversed() + } + } + + var sortedTeams: [TeamRegistration] { + tournaments.flatMap({ $0.selectedSortedTeams() }) + } + + func playerHasValidLicense(_ player: PlayerRegistration) -> Bool { + return true + //todo +// if player.isImported() { +// if let licenseCheck, let licenseYearValidity = event.licenseYearValidity { +// return player.isValidLicenseNumber(year: licenseYearValidity) == licenseCheck +// } else { +// return true +// } +// } else { +// return true +// } + } + + var searchedPlayers: [PlayerRegistration] { + tournaments.flatMap({ $0.unsortedPlayers() }) +// if searchText.trimmed.isEmpty { +// return event.orderedPlayers.filter { playerHasValidLicense($0) } +// } else { +// let search = searchText.trimmed.folding(options: .diacriticInsensitive, locale: .current).lowercased() +// return event.orderedPlayers.filter { $0.canonicalName.contains(search) && playerHasValidLicense($0) } +// } + } + + var filteredPlayers: [PlayerRegistration] { + if licenseMode == false { + return searchedPlayers.filter { filterOption.shouldDisplayPlayer($0) } + } else { + return searchedPlayers + } + } + + var sortedPlayers: [PlayerRegistration] { + switch sortOption { + case .callDate, .team, .round: + return filteredPlayers.sorted(using: .keyPath(\.lastName), .keyPath(\.firstName)) + case .alphabeticalFirstName: + return filteredPlayers.sorted(using: .keyPath(\.firstName), .keyPath(\.lastName)) + case .alphabeticalLastName: + return filteredPlayers.sorted(using: .keyPath(\.lastName), .keyPath(\.firstName)) + case .rank: + return filteredPlayers.sorted(using: .keyPath(\.weight), .keyPath(\.lastName), .keyPath(\.firstName)) + case .age: + return filteredPlayers.sorted(using: .keyPath(\.weight), .keyPath(\.lastName), .keyPath(\.firstName)) + } + } + + func save() { +// do { +// event.objectWillChange.send() +// try viewContext.save() +// } catch { +// // Replace this implementation with code to handle the error appropriately. +// // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. +// let nsError = error as NSError +// fatalError("Unresolved error \(nsError), \(nsError.userInfo)") +// } + } + + func bracketPlayers(in tournament: Tournament) -> [PlayerRegistration] { + tournament.selectedSortedTeams().filter { $0.groupStagePosition != nil }.flatMap { $0.unsortedPlayers() }.filter { orderedPlayers.contains($0) } + } + + func playersForRound(in round: Int, tournament: Tournament) -> [PlayerRegistration] { + tournament.selectedSortedTeams().filter { RoundRule.roundIndex(fromMatchIndex: 0) == round && $0.groupStagePosition == nil }.flatMap { $0.unsortedPlayers() }.filter { orderedPlayers.contains($0) } + } + + var displayOptionView: some View { + DisclosureGroup { + HStack { + Text("Voir") + Spacer() + Picker(selection: $licenseCheck) { + Text("Tous les joueurs").tag(nil as Bool?) + Text("Avec licence valide").tag(true as Bool?) + Text("Sans licence valide").tag(false as Bool?) + } label: { + } + } + HStack { + Text("Filtre") + Spacer() + Picker(selection: $filterOption) { + ForEach(FilterOption.allCases) { filterOption in + Text(filterOption.localizedLabel()).tag(filterOption) + } + } label: { + } + } + + HStack { + Text("Tri") + Spacer() + Picker(selection: $sortOption) { + ForEach(SortOption.allCases) { sortOption in + Text(sortOption.localizedLabel()).tag(sortOption) + } + } label: { + } + } + + HStack { + Text("Ordre") + Spacer() + Picker(selection: $sortOrder) { + Text("Croissant").tag(SortOrder.ascending) + Text("Décroissant").tag(SortOrder.descending) + } label: { + + } + } + } label: { + Text("Options d'affichage") + } + + } + + @ViewBuilder + var sortedPlayersView: some View { + if orderedPlayers.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + Section { + ForEach(orderedPlayers) { player in + computedPlayerView(player) + } + } header: { + HStack { + Text(orderedPlayers.count.formatted() + " joueurs") + } + } + } + } + + var body: some View { + List { + Section { + NavigationLink { + CashierDetailView(tournaments: tournaments) + } label: { + Text("Résumé") + } + } + + Section { + ForEach(tournaments) { tournament in + HStack { + Text(tournament.tournamentTitle()) + Spacer() + Text(tournament.earnings().formatted(.currency(code: "EUR").precision(.fractionLength(0)))) + Text(tournament.paidCompletion().formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) + } + } +// HStack { +// Text("Total") +// Spacer() +// Text(event.earnings.formatted(.currency(code: "EUR").precision(.fractionLength(0)))) +// Text(event.paidCompletion.formatted(.percent.precision(.fractionLength(0)))).foregroundStyle(.secondary) +// } + } header: { + Text("Encaissement") + } + + if licenseMode == false { + Section { + Picker(selection: $filterOption) { + ForEach(FilterOption.allCases) { filterOption in + Text(filterOption.localizedLabel()).tag(filterOption) + } + } label: { + Text("Statut du règlement") + } + + Picker(selection: $sortOption) { + ForEach(SortOption.allCases) { sortOption in + Text(sortOption.localizedLabel()).tag(sortOption) + } + } label: { + Text("Affichage par") + } + + if sortOption != .round { + Picker(selection: $sortOrder) { + Text("Croissant").tag(SortOrder.ascending) + Text("Décroissant").tag(SortOrder.descending) + } label: { + Text(sortOption == .team ? "Tri par rang" : "Tri") + } + } + } header: { + Text("Options d'affichage") + } + } + + if searchText.isEmpty == false { + sortedPlayersView + } else { + + if sortOption == .team && licenseMode == false { + if orderedTeams.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + ForEach(orderedTeams) { team in + Section { + ForEach(team.players()) { player in + computedPlayerView(player) + } + } + } + } + } else if sortOption == .round && licenseMode == false { + if orderedPlayers.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + ForEach(tournaments) { tournament in + let bracketPlayers = bracketPlayers(in: tournament) + Section { + DisclosureGroup { + ForEach(bracketPlayers) { player in + computedPlayerView(player) + } + } label: { + HStack { + Text("Poules") + Spacer() + Text(bracketPlayers.count.formatted()) + } + } + } header: { + Text(tournament.tournamentTitle()) + } + } + + // ForEach(1...event.rounds) { round in + // ForEach(tournaments) { tournament in + // if tournament.isRoundHidden(round) == false { + // let players = playersForRound(in: round, tournament: tournament) + // + // if players.isEmpty == false { + // Section { + // DisclosureGroup { + // ForEach(players) { player in + // computedPlayerView(player) + // } + // } label: { + // HStack { + // Text(RoundLabel.labels[tournament.rounds - round]) + // Spacer() + // Text(players.count.formatted()) + // } + // } + // } header: { + // Text(tournament.localizedTitle) + // } + // } + // } + // } + // } + } + } else if sortOption == .callDate && licenseMode == false { + _byCallDateView() + } else { + sortedPlayersView + } + } + } +// .alert("Licence", isPresented: somePlayerToEdit(), actions: { +// TextField("Licence", text: $licenseToEdit) +// .keyboardType(.asciiCapable) +// .autocorrectionDisabled(true) +// .textContentType(.init(rawValue: "")) +// Button("OK") { +// editPlayer?.license = licenseToEdit +// licenseToEdit = "" +// editPlayer?.objectWillChange.send() +// editPlayer = nil +// save() +// } +// Button("Annuler", role : .cancel) { +// licenseToEdit = "" +// editPlayer = nil +// } +// }) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { +// Button { +// event.orderedPlayers.forEach { player in +// if let registration = player.orderedRegistrations.first(where: { $0.tournament?.tournamentID == player.tournament?.tournamentID }) { +// if registration.paymentType == .notPaid { +// registration.paymentType = .gift +// } +// } else { +// let registration = Registration(context: viewContext) +// registration.paymentType = .gift +// registration.tournament = player.tournament +// player.addToRegistrations(registration) +// } +// save() +// } +// } label: { +// Text("Tout le monde a réglé") +// } +// +// Button { +// event.orderedPlayers.forEach { player in +// if let registration = player.orderedRegistrations.first(where: { $0.tournament?.tournamentID == player.tournament?.tournamentID }) { +// registration.paymentType = .notPaid +// } else { +// let registration = Registration(context: viewContext) +// registration.paymentType = .notPaid +// registration.tournament = player.tournament +// player.addToRegistrations(registration) +// } +// save() +// } +// } label: { +// Text("Personne n'a réglé") +// } +// + } label: { + LabelOptions() + } + } + } + .navigationTitle("Joueurs") + .navigationBarTitleDisplayMode(.large) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher un joueur")) + } + + @ViewBuilder + func computedPlayerView(_ player: PlayerRegistration) -> some View { + VStack(alignment: .leading) { + ImportedPlayerView(player: player) + HStack { + Menu { + if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "tel:\(number)") { + Link(destination: url) { + Label("Appeler", systemImage: "phone") + } + } + if let number = player.phoneNumber?.replacingOccurrences(of: " ", with: ""), let url = URL(string: "sms:\(number)") { + Link(destination: url) { + Label("SMS", systemImage: "message") + } + } + + Divider() + if let licenseCheck, let licenseYearValidity = player.tournament()?.licenseYearValidity(), licenseCheck == false { + Button { + player.validateLicenceId(licenseYearValidity) + save() + + if filteredPlayers.isEmpty { + dismiss() + } + + } label: { + Text("Valider la licence \(licenseYearValidity)") + } + } + + if let license = player.licenceId?.strippedLicense { + Button { + let pasteboard = UIPasteboard.general + pasteboard.string = license + } label: { + Label("Copier la licence", systemImage: "doc.on.doc") + } + } + + Section { + Button { + licenseToEdit = player.licenceId ?? "" + editPlayer = player + } label: { + if player.licenceId == nil { + Text("Ajouter la licence") + } else { + Text("Modifier la licence") + } + } + PasteButton(payloadType: String.self) { strings in + guard let first = strings.first else { return } + player.licenceId = first + } + } header: { + Text("Modification de licence") + } + } label: { + Text("Options") + } + Spacer() + PlayerPayView(player: player) + } + } + } + + + private func _byCallDateView() -> some View { + ForEach(tournaments) { tournament in + let teams = tournament.selectedSortedTeams() + let players = teams.filter({ $0.callDate != nil }).sorted(using: .keyPath(\.callDate!)).flatMap({ $0.players() }) + teams.filter({ $0.callDate == nil }).flatMap({ $0.players() }) + Section { + ForEach(players) { player in + computedPlayerView(player) + } + } header: { + Text(tournament.tournamentTitle()) + } + .headerProminence(.increased) + } + } +} diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index 2394e49..230fca8 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -143,6 +143,10 @@ struct InscriptionManagerView: View { if tournament.teamSorting == .inscriptionDate { Divider() _prioritizeClubMembersButton() + + Button("Bloquer une place") { + _createTeam() + } } Divider() Button { diff --git a/PadelClub/Views/Tournament/Screen/Screen.swift b/PadelClub/Views/Tournament/Screen/Screen.swift index 5ac9fed..d9ffa1e 100644 --- a/PadelClub/Views/Tournament/Screen/Screen.swift +++ b/PadelClub/Views/Tournament/Screen/Screen.swift @@ -14,4 +14,6 @@ enum Screen: String, Codable { case settings case structure case schedule + case cashier + case call } diff --git a/PadelClub/Views/Tournament/Screen/TournamentCallView.swift b/PadelClub/Views/Tournament/Screen/TournamentCallView.swift new file mode 100644 index 0000000..3f10f50 --- /dev/null +++ b/PadelClub/Views/Tournament/Screen/TournamentCallView.swift @@ -0,0 +1,79 @@ +// +// TournamentCallView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/04/2024. +// + +import SwiftUI + +enum CallDestination: String, Identifiable, Selectable { + case seeds + case groupStages + + var id: String { self.rawValue } + + func selectionLabel() -> String { + switch self { + case .seeds: + return "Têtes de série" + case .groupStages: + return "Poules" + } + } + + func badgeValue() -> Int? { + nil + } +} + + +struct TournamentCallView: View { + var tournament: Tournament + @State private var selectedDestination: CallDestination? + let allDestinations: [CallDestination] + + init(tournament: Tournament) { + self.tournament = tournament + var destinations = [CallDestination]() + let groupStageTeams = tournament.groupStageTeams() + if groupStageTeams.isEmpty == false { + destinations.append(.groupStages) + self._selectedDestination = State(wrappedValue: .groupStages) + } + if tournament.seededTeams().isEmpty == false { + destinations.append(.seeds) + if groupStageTeams.isEmpty { + self._selectedDestination = State(wrappedValue: .seeds) + } + } + self.allDestinations = destinations + } + + var body: some View { + VStack(spacing: 0) { + GenericDestinationPickerView(selectedDestination: $selectedDestination, destinations: allDestinations, nilDestinationIsValid: true) + switch selectedDestination { + case .none: + CallSettingsView() + .navigationTitle("Réglages") + case .some(let selectedCall): + switch selectedCall { + case .groupStages: + GroupStageCallingView() + case .seeds: + SeedsCallingView() + } + } + } + .environment(tournament) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .navigationTitle("Convocations") + } + +} + +#Preview { + TournamentCallView(tournament: Tournament.mock()) +} diff --git a/PadelClub/Views/Tournament/TournamentRunningView.swift b/PadelClub/Views/Tournament/TournamentRunningView.swift index 9f8209b..fad5286 100644 --- a/PadelClub/Views/Tournament/TournamentRunningView.swift +++ b/PadelClub/Views/Tournament/TournamentRunningView.swift @@ -20,6 +20,22 @@ struct TournamentRunningView: View { Text("Horaires") } } + + NavigationLink(value: Screen.call) { + LabeledContent { + Text(tournament.callStatus()) + } label: { + Text("Convocations") + } + } + + NavigationLink(value: Screen.cashier) { + LabeledContent { + Text(tournament.cashierStatus()) + } label: { + Text("Encaissement") + } + } } if tournament.groupStages().isEmpty == false { diff --git a/PadelClub/Views/Tournament/TournamentView.swift b/PadelClub/Views/Tournament/TournamentView.swift index 73f9a29..0c07383 100644 --- a/PadelClub/Views/Tournament/TournamentView.swift +++ b/PadelClub/Views/Tournament/TournamentView.swift @@ -80,6 +80,10 @@ struct TournamentView: View { RoundsView(tournament: tournament) case .schedule: TournamentScheduleView(tournament: tournament) + case .cashier: + CashierView(tournament: tournament) + case .call: + TournamentCallView(tournament: tournament) } } .environment(tournament)