diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 0023abe..eac2f96 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3310,7 +3310,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.34; + MARKETING_VERSION = 1.0.40; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3354,7 +3354,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.34; + MARKETING_VERSION = 1.0.40; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index d4ade32..36f8377 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -1533,7 +1533,7 @@ defer { return unsortedTeams().first(where: { $0.includes(players: players) }) } - func tournamentTitle(_ displayStyle: DisplayStyle = .wide) -> String { + func tournamentTitle(_ displayStyle: DisplayStyle = .wide, hideSenior: Bool = false) -> String { if tournamentLevel == .unlisted, displayStyle == .title { if let name { return name @@ -1541,7 +1541,13 @@ defer { return tournamentLevel.localizedLevelLabel(.title) } } - let title: String = [tournamentLevel.localizedLevelLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle), federalTournamentAge.localizedFederalAgeLabel(displayStyle)].filter({ $0.isEmpty == false }).joined(separator: " ") + let displayStyleCategory = hideSenior ? .short : displayStyle + var levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle)] + if displayStyle == .short { + levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle) + tournamentCategory.localizedLabel(displayStyle)] + } + let array = levelCategory + [federalTournamentAge.localizedFederalAgeLabel(displayStyleCategory)] + let title: String = array.filter({ $0.isEmpty == false }).joined(separator: " ") if displayStyle == .wide, let name { return [title, name].joined(separator: " - ") } else { diff --git a/PadelClub/Extensions/String+Extensions.swift b/PadelClub/Extensions/String+Extensions.swift index 491f0c6..d1e895b 100644 --- a/PadelClub/Extensions/String+Extensions.swift +++ b/PadelClub/Extensions/String+Extensions.swift @@ -170,8 +170,7 @@ extension String { // MARK: - FFT Source Importing 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})$/ + static let mobileNumber = /^(?:\+33|0033|0)[6-7](?:[ .-]?[0-9]{2}){4}$/ } func isMobileNumber() -> Bool { diff --git a/PadelClub/Utils/ContactManager.swift b/PadelClub/Utils/ContactManager.swift index 8c620a7..a72204a 100644 --- a/PadelClub/Utils/ContactManager.swift +++ b/PadelClub/Utils/ContactManager.swift @@ -82,7 +82,7 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c let date = startDate ?? tournament?.startDate ?? Date() if let tournament { - text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.short)) + text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.title, hideSenior: true)) text = text.replacingOccurrences(of: "#prix", with: tournament.entryFeeMessage) } @@ -132,7 +132,7 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes" if let tournament { - return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)" + return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.title, hideSenior: true)) 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\n\(intro) \(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)" } diff --git a/PadelClub/Utils/PadelRule.swift b/PadelClub/Utils/PadelRule.swift index fc85147..b346a14 100644 --- a/PadelClub/Utils/PadelRule.swift +++ b/PadelClub/Utils/PadelRule.swift @@ -549,7 +549,7 @@ enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { case .p25: switch count { case 9...12: - return [17, 13, 11, 9, 7, 5, 4, 3, 2, 1] + return [17, 15, 13, 11, 9, 7, 5, 4, 3, 2, 1] case 13...16: return [18,16,15,14,13,12,11,10,9,7,5,4,3,2, 1] case 17...20: diff --git a/PadelClub/Utils/Patcher.swift b/PadelClub/Utils/Patcher.swift index 66a71ee..6841bf4 100644 --- a/PadelClub/Utils/Patcher.swift +++ b/PadelClub/Utils/Patcher.swift @@ -13,9 +13,7 @@ enum PatchError: Error { } enum Patch: String, CaseIterable { - case alexisLeDu - case importDataFromDevToProd - case fixMissingMatches + case cleanLogs var id: String { return "padelclub.app.patch.\(self.rawValue)" @@ -44,121 +42,122 @@ class Patcher { fileprivate static func _applyPatch(_ patch: Patch) throws { switch patch { - case .alexisLeDu: self._patchAlexisLeDu() - case .importDataFromDevToProd: try self._importDataFromDev() - case .fixMissingMatches: self._patchMissingMatches() + case .cleanLogs: self._cleanLogs() } } +// +// fileprivate static func _patchAlexisLeDu() { +// guard StoreCenter.main.userId == "94f45ed2-8938-4c32-a4b6-e4525073dd33" else { return } +// +// let clubs = DataStore.shared.clubs +// StoreCenter.main.resetApiCalls(collection: clubs) +//// clubs.resetApiCalls() +// +// for club in clubs.filter({ $0.creator == "d5060b89-e979-4c19-bf78-e459a6ed5318"}) { +// club.creator = StoreCenter.main.userId +// clubs.writeChangeAndInsertOnServer(instance: club) +// } +// +// } +// +// fileprivate static func _importDataFromDev() throws { +// +// let devServices = Services(url: "https://xlr.alwaysdata.net/roads/") +// guard devServices.hasToken() else { +// return +// } +// guard StoreCenter.main.synchronizationApiURL == "https://padelclub.app/roads/" else { +// return +// } +// +// guard let userId = StoreCenter.main.userId else { +// return +// } +// +// try StoreCenter.main.migrateToken(devServices) +// +// +// let myClubs: [Club] = DataStore.shared.clubs.filter { $0.creator == userId } +// let clubIds: [String] = myClubs.map { $0.id } +// +// myClubs.forEach { club in +// DataStore.shared.clubs.insertIntoCurrentService(item: club) +// +// let courts = DataStore.shared.courts.filter { clubIds.contains($0.club) } +// for court in courts { +// DataStore.shared.courts.insertIntoCurrentService(item: court) +// } +// } +// +// DataStore.shared.user.clubs = Array(clubIds) +// DataStore.shared.saveUser() +// +// DataStore.shared.events.insertAllIntoCurrentService() +// DataStore.shared.tournaments.insertAllIntoCurrentService() +// DataStore.shared.dateIntervals.insertAllIntoCurrentService() +// +// for tournament in DataStore.shared.tournaments { +// let store = tournament.tournamentStore +// +// Task { // need to wait for the collections to load +// try await Task.sleep(until: .now + .seconds(2)) +// +// store.teamRegistrations.insertAllIntoCurrentService() +// store.rounds.insertAllIntoCurrentService() +// store.groupStages.insertAllIntoCurrentService() +// store.matches.insertAllIntoCurrentService() +// store.playerRegistrations.insertAllIntoCurrentService() +// store.teamScores.insertAllIntoCurrentService() +// +// } +// } +// +// } +// +// fileprivate static func _patchMissingMatches() { +// +// guard let url = StoreCenter.main.synchronizationApiURL else { +// return +// } +// guard url == "https://padelclub.app/roads/" else { +// return +// } +// let services = Services(url: url) +// +// for tournament in DataStore.shared.tournaments { +// +// let store = tournament.tournamentStore +// let identifier = StoreIdentifier(value: tournament.id, parameterName: "tournament") +// +// Task { +// +// do { +// // if nothing is online we upload the data +// let matches: [Match] = try await services.get(identifier: identifier) +// if matches.isEmpty { +// store.matches.insertAllIntoCurrentService() +// } +// +// let playerRegistrations: [PlayerRegistration] = try await services.get(identifier: identifier) +// if playerRegistrations.isEmpty { +// store.playerRegistrations.insertAllIntoCurrentService() +// } +// +// let teamScores: [TeamScore] = try await services.get(identifier: identifier) +// if teamScores.isEmpty { +// store.teamScores.insertAllIntoCurrentService() +// } +// +// } catch { +// Logger.error(error) +// } +// +// } +// } +// +// } - fileprivate static func _patchAlexisLeDu() { - guard StoreCenter.main.userId == "94f45ed2-8938-4c32-a4b6-e4525073dd33" else { return } - - let clubs = DataStore.shared.clubs - StoreCenter.main.resetApiCalls(collection: clubs) -// clubs.resetApiCalls() - - for club in clubs.filter({ $0.creator == "d5060b89-e979-4c19-bf78-e459a6ed5318"}) { - club.creator = StoreCenter.main.userId - clubs.writeChangeAndInsertOnServer(instance: club) - } - - } - - fileprivate static func _importDataFromDev() throws { - - let devServices = Services(url: "https://xlr.alwaysdata.net/roads/") - guard devServices.hasToken() else { - return - } - guard StoreCenter.main.synchronizationApiURL == "https://padelclub.app/roads/" else { - return - } - - guard let userId = StoreCenter.main.userId else { - return - } - - try StoreCenter.main.migrateToken(devServices) - - - let myClubs: [Club] = DataStore.shared.clubs.filter { $0.creator == userId } - let clubIds: [String] = myClubs.map { $0.id } - - myClubs.forEach { club in - DataStore.shared.clubs.insertIntoCurrentService(item: club) - - let courts = DataStore.shared.courts.filter { clubIds.contains($0.club) } - for court in courts { - DataStore.shared.courts.insertIntoCurrentService(item: court) - } - } - - DataStore.shared.user.clubs = Array(clubIds) - DataStore.shared.saveUser() - - DataStore.shared.events.insertAllIntoCurrentService() - DataStore.shared.tournaments.insertAllIntoCurrentService() - DataStore.shared.dateIntervals.insertAllIntoCurrentService() - - for tournament in DataStore.shared.tournaments { - let store = tournament.tournamentStore - - Task { // need to wait for the collections to load - try await Task.sleep(until: .now + .seconds(2)) - - store.teamRegistrations.insertAllIntoCurrentService() - store.rounds.insertAllIntoCurrentService() - store.groupStages.insertAllIntoCurrentService() - store.matches.insertAllIntoCurrentService() - store.playerRegistrations.insertAllIntoCurrentService() - store.teamScores.insertAllIntoCurrentService() - - } - } - - } - - fileprivate static func _patchMissingMatches() { - - guard let url = StoreCenter.main.synchronizationApiURL else { - return - } - guard url == "https://padelclub.app/roads/" else { - return - } - let services = Services(url: url) - - for tournament in DataStore.shared.tournaments { - - let store = tournament.tournamentStore - let identifier = StoreIdentifier(value: tournament.id, parameterName: "tournament") - - Task { - - do { - // if nothing is online we upload the data - let matches: [Match] = try await services.get(identifier: identifier) - if matches.isEmpty { - store.matches.insertAllIntoCurrentService() - } - - let playerRegistrations: [PlayerRegistration] = try await services.get(identifier: identifier) - if playerRegistrations.isEmpty { - store.playerRegistrations.insertAllIntoCurrentService() - } - - let teamScores: [TeamScore] = try await services.get(identifier: identifier) - if teamScores.isEmpty { - store.teamScores.insertAllIntoCurrentService() - } - - } catch { - Logger.error(error) - } - - } - } - + fileprivate static func _cleanLogs() { + StoreCenter.main.resetLoggingCollections() } - } diff --git a/PadelClub/Utils/Tips.swift b/PadelClub/Utils/Tips.swift index 0faba01..c3e145c 100644 --- a/PadelClub/Utils/Tips.swift +++ b/PadelClub/Utils/Tips.swift @@ -549,6 +549,35 @@ struct TeamsExportTip: Tip { } } +struct TimeSlotMoveTip: Tip { + var title: Text { + Text("Réorganisez vos créneaux horaires !") + } + + var message: Text? { + Text("Vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.") + } + + var image: Image? { + Image(systemName: "arrow.up.arrow.down.circle") + } +} + +struct TimeSlotMoveOptionTip: Tip { + var title: Text { + Text("Réorganisez vos créneaux horaires !") + } + + var message: Text? { + Text("En cliquant ici, vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.") + } + + var image: Image? { + Image(systemName: "sparkles") + } +} + + struct PlayerTournamentSearchTip: Tip { var title: Text { Text("Cherchez un tournoi autour de vous !") diff --git a/PadelClub/Views/Calling/CallMessageCustomizationView.swift b/PadelClub/Views/Calling/CallMessageCustomizationView.swift index 0ed9a54..2914565 100644 --- a/PadelClub/Views/Calling/CallMessageCustomizationView.swift +++ b/PadelClub/Views/Calling/CallMessageCustomizationView.swift @@ -56,7 +56,7 @@ struct CallMessageCustomizationView: View { var finalMessage: String? { let localizedCalled = "convoqué" + (tournament.tournamentCategory == .women ? "e" : "") + "s" - return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)" + return "Bonjour,\n\nVous êtes \(localizedCalled) pour jouer en \(RoundRule.roundName(fromRoundIndex: 2).lowercased()) du \(tournament.tournamentTitle(.title, hideSenior: true)) au \(clubName) le \(tournament.startDate.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(tournament.startDate.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(customCallMessageSignature)" } var body: some View { diff --git a/PadelClub/Views/Calling/CallView.swift b/PadelClub/Views/Calling/CallView.swift index 86f12af..1be6236 100644 --- a/PadelClub/Views/Calling/CallView.swift +++ b/PadelClub/Views/Calling/CallView.swift @@ -387,7 +387,7 @@ struct CallView: View { recipients: tournament.umpireMail(), bccRecipients: teams.flatMap { $0.getMail() }, body: finalMessage(reSummon: reSummon, forcedEmptyMessage: forcedEmptyMessage), - subject: tournament.tournamentTitle(), + subject: tournament.tournamentTitle(hideSenior: true), tournamentBuild: nil) } diff --git a/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift b/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift index 4e8d78e..cffae6e 100644 --- a/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift +++ b/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift @@ -31,7 +31,7 @@ struct PlayersWithoutContactView: View { } } - let withoutPhones = players.filter({ $0.phoneNumber?.isEmpty == true || $0.phoneNumber == nil }) + let withoutPhones = players.filter({ $0.phoneNumber?.isEmpty == true || $0.phoneNumber == nil || $0.phoneNumber?.isMobileNumber() == false }) DisclosureGroup { ForEach(withoutPhones) { player in NavigationLink { @@ -45,7 +45,7 @@ struct PlayersWithoutContactView: View { LabeledContent { Text(withoutPhones.count.formatted()) } label: { - Text("Joueurs sans téléphone") + Text("Joueurs sans téléphone portable") } } } header: { diff --git a/PadelClub/Views/Calling/SendToAllView.swift b/PadelClub/Views/Calling/SendToAllView.swift index 9f6778d..d261f35 100644 --- a/PadelClub/Views/Calling/SendToAllView.swift +++ b/PadelClub/Views/Calling/SendToAllView.swift @@ -273,7 +273,7 @@ struct SendToAllView: View { if contactMethod == 0 { contactType = .message(date: nil, recipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.phoneNumber }, body: finalMessage(), tournamentBuild: nil) } else { - contactType = .mail(date: nil, recipients: tournament.umpireMail(), bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.email }, body: finalMessage(), subject: tournament.tournamentTitle(), tournamentBuild: nil) + contactType = .mail(date: nil, recipients: tournament.umpireMail(), bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.email }, body: finalMessage(), subject: tournament.tournamentTitle(hideSenior: true), tournamentBuild: nil) } } } diff --git a/PadelClub/Views/Navigation/MainView.swift b/PadelClub/Views/Navigation/MainView.swift index ee01859..62f9ceb 100644 --- a/PadelClub/Views/Navigation/MainView.swift +++ b/PadelClub/Views/Navigation/MainView.swift @@ -263,7 +263,7 @@ struct MainView: View { await _startImporting(importingDate: mostRecentDateImported) } else if current.dataModelIdentifier != PersistenceController.getModelVersion() && current.fileModelIdentifier != fileURL?.fileModelIdentifier() { await _startImporting(importingDate: mostRecentDateImported) - } else if current.incompleteMode == false || updated == 0 { + } else if updated == 0 { await _calculateMonthData(dataSource: current.monthKey) } } diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index 3a6e07f..0f3193c 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -6,16 +6,21 @@ // import SwiftUI +import LeStorage +import TipKit struct PlanningView: View { + @EnvironmentObject var dataStore: DataStore @Environment(Tournament.self) var tournament: Tournament @State private var selectedDay: Date? @Binding var selectedScheduleDestination: ScheduleDestination? @State private var filterOption: PlanningFilterOption = .byDefault @State private var showFinishedMatches: Bool = false + @State private var enableMove: Bool = false let allMatches: [Match] + let timeSlotMoveOptionTip = TimeSlotMoveOptionTip() init(matches: [Match], selectedScheduleDestination: Binding) { self.allMatches = matches @@ -37,23 +42,7 @@ struct PlanningView: View { func keys(timeSlots: [Date:[Match]]) -> [Date] { timeSlots.keys.sorted() } - - enum PlanningFilterOption: Int, CaseIterable, Identifiable { - var id: Int { self.rawValue } - - case byDefault - case byCourt - func localizedPlanningLabel() -> String { - switch self { - case .byCourt: - return "Par terrain" - case .byDefault: - return "Par ordre des matchs" - } - } - } - private func _computedTitle(days: [Date]) -> String { if let selectedDay { return selectedDay.formatted(.dateTime.day().weekday().month()) @@ -71,8 +60,13 @@ struct PlanningView: View { let keys = self.keys(timeSlots: timeSlots) let days = self.days(timeSlots: timeSlots) let matches = matches - BySlotView(days: days, keys: keys, timeSlots: timeSlots, matches: matches, selectedDay: selectedDay, filterOption: filterOption, showFinishedMatches: showFinishedMatches) + let notSlots = matches.allSatisfy({ $0.startDate == nil }) + BySlotView(days: days, keys: keys, timeSlots: timeSlots, matches: matches, selectedDay: selectedDay) + .environment(\.filterOption, filterOption) + .environment(\.showFinishedMatches, showFinishedMatches) + .environment(\.enableMove, enableMove) .navigationTitle(Text(_computedTitle(days: days))) + .navigationBarBackButtonHidden(enableMove) .toolbar(content: { if days.count > 1 { ToolbarTitleMenu { @@ -89,42 +83,79 @@ struct PlanningView: View { Text("Jour") } .pickerStyle(.automatic) + .disabled(enableMove) } } + + if enableMove { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler") { + enableMove = false + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Sauver") { + do { + try self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: allMatches) + } catch { + Logger.error(error) + } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu { - Picker(selection: $showFinishedMatches) { - Text("Afficher tous les matchs").tag(true) - Text("Masquer les matchs terminés").tag(false) - } label: { - Text("Option de filtrage") + enableMove = false } - .labelsHidden() - .pickerStyle(.inline) - } label: { - Label("Filtrer", systemImage: "clock.badge.checkmark") - .symbolVariant(showFinishedMatches ? .fill : .none) } - Menu { - Picker(selection: $filterOption) { - ForEach(PlanningFilterOption.allCases) { - Text($0.localizedPlanningLabel()).tag($0) + + } else { + + ToolbarItemGroup(placement: .topBarTrailing) { + if notSlots == false { + Toggle(isOn: $enableMove) { + Label("Déplacer", systemImage: "rectangle.2.swap") + } + .popoverTip(timeSlotMoveOptionTip) + } + + Menu { + Section { + Picker(selection: $showFinishedMatches) { + Text("Afficher tous les matchs").tag(true) + Text("Masquer les matchs terminés").tag(false) + } label: { + Text("Option de filtrage") + } + .labelsHidden() + .pickerStyle(.inline) + } header: { + Text("Option de filtrage") + } + + Divider() + + Section { + Picker(selection: $filterOption) { + ForEach(PlanningFilterOption.allCases) { + Text($0.localizedPlanningLabel()).tag($0) + } + } label: { + Text("Option de triage") + } + .labelsHidden() + .pickerStyle(.inline) + } header: { + Text("Option de triage") + } } label: { - Text("Option de triage") + Label("Trier", systemImage: "line.3.horizontal.decrease.circle") + .symbolVariant(filterOption == .byCourt || showFinishedMatches ? .fill : .none) } - .labelsHidden() - .pickerStyle(.inline) - } label: { - Label("Trier", systemImage: "line.3.horizontal.decrease.circle") - .symbolVariant(filterOption == .byCourt ? .fill : .none) + } - } }) .overlay { - if matches.allSatisfy({ $0.startDate == nil }) { + if notSlots { ContentUnavailableView { Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") } description: { @@ -140,86 +171,213 @@ struct PlanningView: View { struct BySlotView: View { @Environment(Tournament.self) var tournament: Tournament + @Environment(\.filterOption) private var filterOption + @Environment(\.showFinishedMatches) private var showFinishedMatches + @Environment(\.enableMove) private var enableMove + let days: [Date] let keys: [Date] - let timeSlots: [Date:[Match]] + let timeSlots: [Date: [Match]] let matches: [Match] let selectedDay: Date? - let filterOption: PlanningFilterOption - let showFinishedMatches: Bool + let timeSlotMoveTip = TimeSlotMoveTip() var body: some View { List { - if matches.allSatisfy({ $0.startDate == nil }) == false { + + if enableMove { + TipView(timeSlotMoveTip) + .tipStyle(tint: .logoYellow, asSection: true) + } + + if !matches.allSatisfy({ $0.startDate == nil }) { ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in - Section { - ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in - if let _matches = timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) { - DisclosureGroup { - ForEach(_matches) { match in - NavigationLink { - MatchDetailView(match: match) - .matchViewStyle(.sectionedStandardStyle) - - } label: { - LabeledContent { - if let courtName = match.courtName() { - Text(courtName) - } - } label: { - if let groupStage = match.groupStageObject { - Text(groupStage.groupStageTitle(.title)) - } else if let round = match.roundObject { - Text(round.roundTitle()) - } - Text(match.matchTitle()) - } - } - } - } label: { - _timeSlotView(key: key, matches: _matches) - } - } - } - } header: { - HStack { - if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { - Text("Sans horaire") - } else { - Text(day.formatted(.dateTime.day().weekday().month())) - } - Spacer() - let count = _matchesCount(inDayInt: day.dayInt, timeSlots: timeSlots) - if showFinishedMatches { - Text(self._formattedMatchCount(count)) - } else { - Text(self._formattedMatchCount(count) + " restant\(count.pluralSuffix)") - } - } - } footer: { - if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { - Text("Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages.") - } - } - .headerProminence(.increased) + DaySectionView( + day: day, + keys: keys.filter({ $0.dayInt == day.dayInt }), + timeSlots: timeSlots, + selectedDay: selectedDay + ) } } } } + } + + + struct DaySectionView: View { + @Environment(Tournament.self) var tournament: Tournament + @Environment(\.filterOption) private var filterOption + @Environment(\.showFinishedMatches) private var showFinishedMatches + @Environment(\.enableMove) private var enableMove + + let day: Date + let keys: [Date] + let timeSlots: [Date: [Match]] + let selectedDay: Date? + + var body: some View { + Section { + ForEach(keys, id: \.self) { key in + TimeSlotSectionView( + key: key, + matches: timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) ?? [] + ) + } + .onMove(perform: enableMove ? moveSection : nil) + } header: { + HeaderView(day: day, timeSlots: timeSlots) + } footer: { + if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + Text("Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages.") + } + } + } - private func _matchesCount(inDayInt dayInt: Int, timeSlots: [Date:[Match]]) -> Int { + func moveSection(from source: IndexSet, to destination: Int) { + let daySlots = keys.filter { $0.dayInt == day.dayInt }.sorted() + + guard let sourceIdx = source.first, + sourceIdx < daySlots.count, + destination <= daySlots.count else { + return + } + + // Create a mutable copy of the time slots for this day + var slotsToUpdate = daySlots + + let updateRange = min(sourceIdx, destination)...max(sourceIdx, destination) + + // Perform the move in the array + let sourceTime = slotsToUpdate.remove(at: sourceIdx) + if sourceIdx < destination { + slotsToUpdate.insert(sourceTime, at: destination - 1) + } else { + slotsToUpdate.insert(sourceTime, at: destination) + } + + // Update matches by swapping their startDates + for index in updateRange { + // Find the new time slot for these matches + let oldStartTime = slotsToUpdate[index] + let newStartTime = daySlots[index] + guard let matchesToUpdate = timeSlots[oldStartTime] else { continue } + + // Update each match with the new start time + for match in matchesToUpdate { + match.startDate = newStartTime + } + } + } + } + + + struct TimeSlotSectionView: View { + @Environment(\.enableMove) private var enableMove + let key: Date + let matches: [Match] + + var body: some View { + if !matches.isEmpty { + if enableMove { + TimeSlotHeaderView(key: key, matches: matches) + } else { + DisclosureGroup { + MatchListView(matches: matches) + } label: { + TimeSlotHeaderView(key: key, matches: matches) + } + } + } + } + } + + struct MatchListView: View { + let matches: [Match] + + var body: some View { + ForEach(matches) { match in + NavigationLink { + MatchDetailView(match: match) + .matchViewStyle(.sectionedStandardStyle) + } label: { + MatchRowView(match: match) + } + } + } + } + + struct MatchRowView: View { + let match: Match + + var body: some View { + LabeledContent { + if let courtName = match.courtName() { + Text(courtName) + } + } label: { + if let groupStage = match.groupStageObject { + Text(groupStage.groupStageTitle(.title)) + } else if let round = match.roundObject { + Text(round.roundTitle()) + } + Text(match.matchTitle()) + } + } + } + + + struct HeaderView: View { + @Environment(\.filterOption) private var filterOption + @Environment(\.showFinishedMatches) private var showFinishedMatches + @Environment(\.enableMove) private var enableMove + + let day: Date + let timeSlots: [Date: [Match]] + + var body: some View { + HStack { + if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + Text("Sans horaire") + } else { + Text(day.formatted(.dateTime.day().weekday().month())) + } + Spacer() + let count = _matchesCount(inDayInt: day.dayInt, timeSlots: timeSlots) + if showFinishedMatches { + Text(_formattedMatchCount(count)) + } else { + Text("\(_formattedMatchCount(count)) restant\(count.pluralSuffix)") + } + } + } + + private func _matchesCount(inDayInt dayInt: Int, timeSlots: [Date: [Match]]) -> Int { timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count } - - private func _timeSlotView(key: Date, matches: [Match]) -> some View { + + private func _formattedMatchCount(_ count: Int) -> String { + return "\(count.formatted()) match\(count.pluralSuffix)" + } + } + + struct TimeSlotHeaderView: View { + let key: Date + let matches: [Match] + @Environment(Tournament.self) var tournament: Tournament + + var body: some View { LabeledContent { - Text(self._formattedMatchCount(matches.count)) + Text("\(matches.count.formatted()) match\(matches.count.pluralSuffix)") } label: { if key.monthYearFormatted == Date.distantFuture.monthYearFormatted { Text("Aucun horaire") } else { - Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) + Text(key.formatted(date: .omitted, time: .shortened)) + .font(.title) + .fontWeight(.semibold) } + if matches.count <= tournament.courtCount { let names = matches.sorted(by: \.computedOrder) .compactMap({ $0.roundTitle() }) @@ -232,15 +390,203 @@ struct PlanningView: View { } else { Text(matches.count.formatted().appending(" matchs")) } + } } - - fileprivate func _formattedMatchCount(_ count: Int) -> String { - return "\(count.formatted()) match\(count.pluralSuffix)" + } + + +// struct BySlotView: View { +// @Environment(Tournament.self) var tournament: Tournament +// let days: [Date] +// let keys: [Date] +// let timeSlots: [Date:[Match]] +// let matches: [Match] +// let selectedDay: Date? +// let filterOption: PlanningFilterOption +// let showFinishedMatches: Bool +// +// var body: some View { +// List { +// if matches.allSatisfy({ $0.startDate == nil }) == false { +// ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in +// Section { +// ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in +// if let _matches = timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) { +// DisclosureGroup { +// ForEach(_matches) { match in +// NavigationLink { +// MatchDetailView(match: match) +// .matchViewStyle(.sectionedStandardStyle) +// +// } label: { +// LabeledContent { +// if let courtName = match.courtName() { +// Text(courtName) +// } +// } label: { +// if let groupStage = match.groupStageObject { +// Text(groupStage.groupStageTitle(.title)) +// } else if let round = match.roundObject { +// Text(round.roundTitle()) +// } +// Text(match.matchTitle()) +// } +// } +// } +// } label: { +// _timeSlotView(key: key, matches: _matches) +// } +// } +// } +// .onMove(perform: moveSection) +// } header: { +// HStack { +// if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { +// Text("Sans horaire") +// } else { +// Text(day.formatted(.dateTime.day().weekday().month())) +// } +// Spacer() +// let count = _matchesCount(inDayInt: day.dayInt, timeSlots: timeSlots) +// if showFinishedMatches { +// Text(self._formattedMatchCount(count)) +// } else { +// Text(self._formattedMatchCount(count) + " restant\(count.pluralSuffix)") +// } +// } +// } footer: { +// if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { +// Text("Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages.") +// } +// } +// .headerProminence(.increased) +// } +// } +// } +// } +// +// func moveSection(from source: IndexSet, to destination: Int) { +// let daySlots = keys.filter { selectedDay == nil || $0.dayInt == selectedDay?.dayInt }.sorted() +// +// guard let sourceIdx = source.first, +// sourceIdx < daySlots.count, +// destination <= daySlots.count else { +// return +// } +// +// // Create a mutable copy of the time slots for this day +// var slotsToUpdate = daySlots +// +// let updateRange = min(sourceIdx, destination)...max(sourceIdx, destination) - 1 +// print(updateRange) +// +// // Perform the move in the array +// let sourceTime = slotsToUpdate.remove(at: sourceIdx) +// if sourceIdx < destination { +// slotsToUpdate.insert(sourceTime, at: destination - 1) +// } else { +// slotsToUpdate.insert(sourceTime, at: destination) +// } +// +// // Update matches by swapping their startDates +// for index in updateRange { +// // Find the new time slot for these matches +// let oldStartTime = slotsToUpdate[index] +// let newStartTime = daySlots[index] +// guard let matchesToUpdate = timeSlots[oldStartTime] else { continue } +// print("moving", oldStartTime, "to", newStartTime) +// +// // Update each match with the new start time +// for match in matchesToUpdate { +// match.startDate = newStartTime +// } +// } +// +// try? self.tournament.tournamentStore.matches.addOrUpdate(contentOfs: matches) +// } +// +// +// private func _matchesCount(inDayInt dayInt: Int, timeSlots: [Date:[Match]]) -> Int { +// timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count +// } +// +// private func _timeSlotView(key: Date, matches: [Match]) -> some View { +// LabeledContent { +// Text(self._formattedMatchCount(matches.count)) +// } label: { +// if key.monthYearFormatted == Date.distantFuture.monthYearFormatted { +// Text("Aucun horaire") +// } else { +// Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) +// } +// if matches.count <= tournament.courtCount { +// let names = matches.sorted(by: \.computedOrder) +// .compactMap({ $0.roundTitle() }) +// .reduce(into: [String]()) { uniqueNames, name in +// if !uniqueNames.contains(name) { +// uniqueNames.append(name) +// } +// } +// Text(names.joined(separator: ", ")).lineLimit(1).truncationMode(.tail) +// } else { +// Text(matches.count.formatted().appending(" matchs")) +// } +// } +// } +// +// fileprivate func _formattedMatchCount(_ count: Int) -> String { +// return "\(count.formatted()) match\(count.pluralSuffix)" +// } +// } +} + +enum PlanningFilterOption: Int, CaseIterable, Identifiable { + var id: Int { self.rawValue } + + case byDefault + case byCourt + + func localizedPlanningLabel() -> String { + switch self { + case .byCourt: + return "Par terrain" + case .byDefault: + return "Par ordre des matchs" } } } -//#Preview { -// PlanningView(matches: [], selectedScheduleDestination: .constant(nil)) -//} + +struct FilterOptionKey: EnvironmentKey { + static let defaultValue: PlanningFilterOption = .byDefault +} + +extension EnvironmentValues { + var filterOption: PlanningFilterOption { + get { self[FilterOptionKey.self] } + set { self[FilterOptionKey.self] = newValue } + } +} + +struct ShowFinishedMatchesKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var showFinishedMatches: Bool { + get { self[ShowFinishedMatchesKey.self] } + set { self[ShowFinishedMatchesKey.self] = newValue } + } +} + +struct EnableMoveKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var enableMove: Bool { + get { self[EnableMoveKey.self] } + set { self[EnableMoveKey.self] = newValue } + } +}