diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 650c1ec..c3c2b61 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3128,7 +3128,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.37; + MARKETING_VERSION = 1.2.40; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3174,7 +3174,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.37; + MARKETING_VERSION = 1.2.40; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Extensions/Tournament+Extensions.swift b/PadelClub/Extensions/Tournament+Extensions.swift index c637a6f..a928ded 100644 --- a/PadelClub/Extensions/Tournament+Extensions.swift +++ b/PadelClub/Extensions/Tournament+Extensions.swift @@ -333,7 +333,7 @@ extension Tournament { } let rankSourceDate = _mostRecentDateAvailable - return Tournament(rankSourceDate: rankSourceDate) + return Tournament(rankSourceDate: rankSourceDate, currencyCode: Locale.defaultCurrency()) } } diff --git a/PadelClub/Views/Cashier/CashierDetailView.swift b/PadelClub/Views/Cashier/CashierDetailView.swift index 22bba64..d1fbec0 100644 --- a/PadelClub/Views/Cashier/CashierDetailView.swift +++ b/PadelClub/Views/Cashier/CashierDetailView.swift @@ -22,13 +22,17 @@ struct CashierDetailView: View { self.tournaments = [tournament] } + func defaultCurrency() -> String { + tournaments.first?.currencyCode ?? Locale.defaultCurrency() + } + var body: some View { List { if tournaments.count > 1 { Section { LabeledContent { if let remainingAmount { - Text(remainingAmount.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + Text(remainingAmount.formatted(.currency(code: defaultCurrency()).precision(.fractionLength(0)))) } else { ProgressView() } @@ -38,7 +42,7 @@ struct CashierDetailView: View { LabeledContent { if let earnings { - Text(earnings.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + Text(earnings.formatted(.currency(code: defaultCurrency()).precision(.fractionLength(0)))) } else { ProgressView() } @@ -116,7 +120,7 @@ struct CashierDetailView: View { Section { LabeledContent { if let remainingAmount { - Text(remainingAmount.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + Text(remainingAmount.formatted(.currency(code: tournament.defaultCurrency()).precision(.fractionLength(0)))) } else { ProgressView() } @@ -126,7 +130,7 @@ struct CashierDetailView: View { LabeledContent { if let earnings { - Text(earnings.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + Text(earnings.formatted(.currency(code: tournament.defaultCurrency()).precision(.fractionLength(0)))) } else { ProgressView() } @@ -176,10 +180,14 @@ struct CashierDetailView: View { @State private var value: Double? + func defaultCurrency() -> String { + tournaments.first?.currencyCode ?? Locale.defaultCurrency() + } + var body: some View { LabeledContent { if let value { - Text(value.formatted(.currency(code: Locale.defaultCurrency()))) + Text(value.formatted(.currency(code: defaultCurrency()))) } else { ProgressView() } @@ -207,7 +215,7 @@ struct CashierDetailView: View { if players.count > 0 { LabeledContent { let sum = players.compactMap({ $0.paidAmount(tournament) }).reduce(0.0, +) - Text(sum.formatted(.currency(code: Locale.defaultCurrency()))) + Text(sum.formatted(.currency(code: tournament.defaultCurrency()))) } label: { Text(type.localizedLabel()) Text(players.count.formatted()) diff --git a/PadelClub/Views/Cashier/CashierSettingsView.swift b/PadelClub/Views/Cashier/CashierSettingsView.swift index 01110d4..2baab1c 100644 --- a/PadelClub/Views/Cashier/CashierSettingsView.swift +++ b/PadelClub/Views/Cashier/CashierSettingsView.swift @@ -29,7 +29,7 @@ struct CashierSettingsView: View { List { Section { LabeledContent { - TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.defaultCurrency())) + TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: tournament.defaultCurrency())) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity) @@ -38,7 +38,7 @@ struct CashierSettingsView: View { Text("Frais d'inscription") } LabeledContent { - TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: Locale.defaultCurrency())) + TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: tournament.defaultCurrency())) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity) @@ -132,7 +132,7 @@ struct CashierSettingsView: View { if focusedField == ._entryFee { if tournament.isFree() { ForEach(priceTags, id: \.self) { priceTag in - Button(priceTag.formatted(.currency(code: Locale.defaultCurrency()))) { + Button(priceTag.formatted(.currency(code: tournament.defaultCurrency()))) { entryFee = priceTag tournament.entryFee = priceTag focusedField = nil @@ -150,7 +150,7 @@ struct CashierSettingsView: View { } } else if focusedField == ._clubMemberFeeDeduction { ForEach(deductionTags, id: \.self) { deductionTag in - Button(deductionTag.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) { + Button(deductionTag.formatted(.currency(code: tournament.defaultCurrency()).precision(.fractionLength(0)))) { clubMemberFeeDeduction = deductionTag tournament.clubMemberFeeDeduction = deductionTag focusedField = nil diff --git a/PadelClub/Views/Cashier/Event/EventStatusView.swift b/PadelClub/Views/Cashier/Event/EventStatusView.swift index 5cf37ff..7d6a7f1 100644 --- a/PadelClub/Views/Cashier/Event/EventStatusView.swift +++ b/PadelClub/Views/Cashier/Event/EventStatusView.swift @@ -35,6 +35,10 @@ struct EventStatusView: View { init(tournaments: [Tournament]) { self.tournaments = tournaments } + + func defaultCurrency() -> String { + tournaments.first?.currencyCode ?? Locale.defaultCurrency() + } private func _calculateTeamsCount() async { Task { @@ -55,7 +59,7 @@ struct EventStatusView: View { private func _currencyView(value: Double, value2: Double? = nil) -> some View { let maps = [value, value2].compactMap({ $0 }).map { - $0.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0))) + $0.formatted(.currency(code: defaultCurrency()).precision(.fractionLength(0))) } let string = maps.joined(separator: " / ") diff --git a/PadelClub/Views/Match/Components/MatchDateView.swift b/PadelClub/Views/Match/Components/MatchDateView.swift index 9b6ba94..df0e586 100644 --- a/PadelClub/Views/Match/Components/MatchDateView.swift +++ b/PadelClub/Views/Match/Components/MatchDateView.swift @@ -46,7 +46,7 @@ struct MatchDateView: View { if let updatedField { match.setCourt(updatedField) } - match.startDate = currentDate + match.updateStartDate(currentDate, keepPlannedStartDate: true) match.endDate = nil match.confirmed = true _save() @@ -55,7 +55,7 @@ struct MatchDateView: View { if let updatedField { match.setCourt(updatedField) } - match.startDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate) + match.updateStartDate(Calendar.current.date(byAdding: .minute, value: 5, to: currentDate), keepPlannedStartDate: true) match.endDate = nil match.confirmed = true _save() @@ -64,7 +64,7 @@ struct MatchDateView: View { if let updatedField { match.setCourt(updatedField) } - match.startDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate) + match.updateStartDate(Calendar.current.date(byAdding: .minute, value: 15, to: currentDate), keepPlannedStartDate: true) match.endDate = nil match.confirmed = true _save() @@ -73,7 +73,7 @@ struct MatchDateView: View { if let updatedField { match.setCourt(updatedField) } - match.startDate = Calendar.current.date(byAdding: .minute, value: estimatedDuration, to: currentDate) + match.updateStartDate(Calendar.current.date(byAdding: .minute, value: estimatedDuration, to: currentDate), keepPlannedStartDate: true) match.endDate = nil match.confirmed = true _save() @@ -96,7 +96,7 @@ struct MatchDateView: View { } Divider() Button("Retirer l'horaire") { - match.cleanScheduleAndSave() + match.updateStartDate(nil, keepPlannedStartDate: true) } } label: { label diff --git a/PadelClub/Views/Match/EditSharingView.swift b/PadelClub/Views/Match/EditSharingView.swift index b4d2bfa..12a57e2 100644 --- a/PadelClub/Views/Match/EditSharingView.swift +++ b/PadelClub/Views/Match/EditSharingView.swift @@ -5,10 +5,11 @@ // Created by Razmig Sarkissian on 03/02/2024. // -import SwiftUI -import TipKit import CoreTransferable import PadelClubData +import SwiftUI +import TipKit +import AVFoundation struct EditSharingView: View { var match: Match @@ -17,50 +18,61 @@ struct EditSharingView: View { @State private var showCamera: Bool = false @State private var newImage: UIImage? @State private var copied: Bool = false + @State private var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined + @State private var showCameraAccessAlert: Bool = false var shareMessage: String { shareMessage(displayRank: displayRank, displayTeamName: displayTeamTitle) } - + func shareMessage(displayRank: Bool, displayTeamName: Bool) -> String { var messageData: [String] = [] - + if match.hasEnded() == false { var locAndTime: String? if let courtName = match.courtName() { locAndTime = "\(courtName)" } - + if let startDate = match.startDate { - locAndTime = [locAndTime, startDate.formattedAsHourMinute()].compactMap({ $0 }).joined(separator: " à ") + locAndTime = [locAndTime, startDate.formattedAsHourMinute()].compactMap({ $0 }) + .joined(separator: " à ") } - + if let locAndTime, locAndTime.isEmpty == false { messageData.append(locAndTime) } } - + if let tournament = match.currentTournament() { messageData.append(tournament.tournamentTitle()) } - - let message = [match.isLoserBracket ? "Classement" : nil, match.roundTitle(), match.isLoserBracket ? nil : ((match.index > 0 || match.isGroupStage()) ? match.matchTitle(.short) : nil)].compactMap({ $0 }).joined(separator: " ") + + let message = [ + match.isLoserBracket ? "Classement" : nil, match.roundTitle(), + match.isLoserBracket + ? nil + : ((match.index > 0 || match.isGroupStage()) ? match.matchTitle(.short) : nil), + ].compactMap({ $0 }).joined(separator: " ") messageData.append(message) - - guard let labelOne = match.team(.one)?.teamLabelRanked(displayRank: displayRank, displayTeamName: displayTeamName), let labelTwo = match.team(.two)?.teamLabelRanked(displayRank: displayRank, displayTeamName: displayTeamName) else { + + guard + let labelOne = match.team(.one)?.teamLabelRanked( + displayRank: displayRank, displayTeamName: displayTeamName), + let labelTwo = match.team(.two)?.teamLabelRanked( + displayRank: displayRank, displayTeamName: displayTeamName) + else { return messageData.joined(separator: "\n") } - + let players = "\(labelOne)\ncontre\n\(labelTwo)" messageData.append(players) - + messageData.append(match.scoreLabel()) return messageData.joined(separator: "\n") } - - var body: some View { List { @@ -70,7 +82,7 @@ struct EditSharingView: View { TipView(tip) .tipStyle(tint: .green) } - + Section { ZStack { Color.black @@ -93,16 +105,16 @@ struct EditSharingView: View { } else { Section { RowButtonView("Prendre une photo", systemImage: "camera") { - showCamera = true + checkCameraAuthorization() } - } + } } - + Section { Toggle(isOn: $displayRank) { Text("Afficher leurs rangs dans ce tournoi") } - + Toggle(isOn: $displayTeamTitle) { Text("Afficher plutôt le nom de l'équipe") } @@ -124,23 +136,40 @@ struct EditSharingView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { if let newImage { - let photo = Photo(image: Image(uiImage:newImage), caption: shareMessage) + let photo = Photo(image: Image(uiImage: newImage), caption: shareMessage) ShareLink( item: photo, preview: SharePreview( photo.caption, - image: photo.image)) { - Text("Partager") - } - .onAppear { - UIPasteboard.general.string = shareMessage - copied = true - } + image: photo.image) + ) { + Text("Partager") + } + .onAppear { + UIPasteboard.general.string = shareMessage + copied = true + } } else { ShareLink("Partager", item: shareMessage) } } } + .onChange(of: displayTeamTitle) { + copied = false + } + .alert("Accès à l'appareil photo requis", isPresented: $showCameraAccessAlert) { + Button("Annuler", role: .cancel) {} + Button("Paramètres") { + if let settingsURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settingsURL) + } + } + } message: { + Text( + "Pour prendre des photos, autorisez l'accès à l'appareil photo dans les paramètres de l'application." + ) + } + .navigationTitle("Préparation") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) @@ -155,6 +184,34 @@ struct EditSharingView: View { copied = false } } + + func checkCameraAuthorization() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + self.cameraAuthorizationStatus = status + + switch status { + case .authorized: + // Camera access already granted, show camera + self.showCamera = true + case .notDetermined: + // Request camera access + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + self.showCamera = true + } else { + self.showCameraAccessAlert = true + } + } + } + case .denied, .restricted: + // Camera access was previously denied or restricted + self.showCameraAccessAlert = true + @unknown default: + // Handle future cases + self.showCameraAccessAlert = true + } + } } struct Photo: Transferable { diff --git a/PadelClub/Views/Match/MatchDetailView.swift b/PadelClub/Views/Match/MatchDetailView.swift index 467c9f3..2796bd9 100644 --- a/PadelClub/Views/Match/MatchDetailView.swift +++ b/PadelClub/Views/Match/MatchDetailView.swift @@ -336,10 +336,7 @@ struct MatchDetailView: View { } } Button(role: .destructive) { - match.startDate = nil - match.endDate = nil - match.confirmed = false - save() + match.updateStartDate(nil, keepPlannedStartDate: true) } label: { Text("Supprimer l'horaire") } @@ -355,6 +352,7 @@ struct MatchDetailView: View { } Divider() Button(role: .destructive) { + match.updateStartDate(nil, keepPlannedStartDate: false) match.resetTeamScores(outsideOf: []) match.resetMatch() match.confirmed = false diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index 44d873e..0e5689e 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -427,12 +427,11 @@ struct EventListView: View { LabelDelete() } } -// Button() { -// self.showUserSearch = true -// } label: { -// ShareLabel().tint(.orange) -// } - + Button() { + dataStore.deleteTournament(tournament, noSync: true) + } label: { + Label("Soft delete", systemImage: "trash") + } } #endif } diff --git a/PadelClub/Views/Planning/CourtAvailabilitySettingsView.swift b/PadelClub/Views/Planning/CourtAvailabilitySettingsView.swift index 5ef5247..3f68a1f 100644 --- a/PadelClub/Views/Planning/CourtAvailabilitySettingsView.swift +++ b/PadelClub/Views/Planning/CourtAvailabilitySettingsView.swift @@ -262,20 +262,29 @@ struct DateAdjusterView: View { var body: some View { HStack(spacing: 4) { if let matchFormat { - _createButton(label: "-\(matchFormat.defaultEstimatedDuration)m", timeOffset: -matchFormat.defaultEstimatedDuration, component: .minute) - _createButton(label: "+\(matchFormat.defaultEstimatedDuration)m", timeOffset: +matchFormat.defaultEstimatedDuration, component: .minute) + _createButton(label: "-\(matchFormat.getEstimatedDuration())m", timeOffset: -matchFormat.getEstimatedDuration(), component: .minute) + Divider() + _createButton(label: "+\(matchFormat.getEstimatedDuration())m", timeOffset: +matchFormat.getEstimatedDuration(), component: .minute) + Divider() _createButton(label: "-\(matchFormat.estimatedTimeWithBreak)m", timeOffset: -matchFormat.estimatedTimeWithBreak, component: .minute) + Divider() _createButton(label: "+\(matchFormat.estimatedTimeWithBreak)m", timeOffset: +matchFormat.estimatedTimeWithBreak, component: .minute) } else if let time { _createButton(label: "-\(time)m", timeOffset: -time, component: .minute) + Divider() _createButton(label: "-\(time/2)m", timeOffset: -time/2, component: .minute) + Divider() _createButton(label: "+\(time/2)m", timeOffset: time/2, component: .minute) + Divider() _createButton(label: "+\(time)m", timeOffset: time, component: .minute) } else { - _createButton(label: "-1h", timeOffset: -1, component: .hour) + _createButton(label: "-1h", timeOffset: -60, component: .minute) + Divider() _createButton(label: "-30m", timeOffset: -30, component: .minute) + Divider() _createButton(label: "+30m", timeOffset: 30, component: .minute) - _createButton(label: "+1h", timeOffset: 1, component: .hour) + Divider() + _createButton(label: "+1h", timeOffset: 60, component: .minute) } } .font(.headline) @@ -287,11 +296,58 @@ struct DateAdjusterView: View { }) { Text(label) .lineLimit(1) - .font(.footnote) - .underline() .frame(maxWidth: .infinity) // Make buttons take equal space } - .buttonStyle(.borderedProminent) + .buttonStyle(.borderless) + .tint(.master) + } +} + +struct StepAdjusterView: View { + @Binding var step: Int + var time: Int? + var matchFormat: MatchFormat? + + var body: some View { + HStack(spacing: 4) { + if let matchFormat { + _createButton(label: "-\(matchFormat.getEstimatedDuration())m", timeOffset: -matchFormat.getEstimatedDuration(), component: .minute) + Divider() + _createButton(label: "+\(matchFormat.getEstimatedDuration())m", timeOffset: +matchFormat.getEstimatedDuration(), component: .minute) + Divider() + _createButton(label: "-\(matchFormat.estimatedTimeWithBreak)m", timeOffset: -matchFormat.estimatedTimeWithBreak, component: .minute) + Divider() + _createButton(label: "+\(matchFormat.estimatedTimeWithBreak)m", timeOffset: +matchFormat.estimatedTimeWithBreak, component: .minute) + } else if let time { + _createButton(label: "-\(time)m", timeOffset: -time, component: .minute) + Divider() + _createButton(label: "-\(time/2)m", timeOffset: -time/2, component: .minute) + Divider() + _createButton(label: "+\(time/2)m", timeOffset: time/2, component: .minute) + Divider() + _createButton(label: "+\(time)m", timeOffset: time, component: .minute) + } else { + _createButton(label: "-1h", timeOffset: -60, component: .minute) + Divider() + _createButton(label: "-30m", timeOffset: -30, component: .minute) + Divider() + _createButton(label: "+30m", timeOffset: 30, component: .minute) + Divider() + _createButton(label: "+1h", timeOffset: 60, component: .minute) + } + } + .font(.headline) + } + + private func _createButton(label: String, timeOffset: Int, component: Calendar.Component) -> some View { + Button(action: { + step += timeOffset + }) { + Text(label) + .lineLimit(1) + .frame(maxWidth: .infinity) // Make buttons take equal space + } + .buttonStyle(.borderless) .tint(.master) } } diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index f670f48..33137e2 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -110,7 +110,7 @@ struct PlanningView: View { if _confirmationMode() { ToolbarItem(placement: .topBarLeading) { - Button("Annuler") { + Button(enableMove ? "Annuler" : "Terminer") { enableMove = false enableEditionBinding.wrappedValue = false } @@ -130,82 +130,86 @@ struct PlanningView: View { } } } else { - if notSlots == false { - ToolbarItemGroup(placement: .bottomBar) { - HStack { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + if notSlots == false { CourtOptionsView(timeSlots: timeSlots, underlined: false) - Spacer() Toggle(isOn: $enableMove) { Label { - Text("Déplacer") + Text("Déplacer un créneau") } icon: { Image(systemName: "rectangle.2.swap") } } .popoverTip(timeSlotMoveOptionTip) .disabled(_confirmationMode()) - Spacer() Toggle(isOn: enableEditionBinding) { - Text("Modifier") + Text("Modifier un horaire") } .disabled(_confirmationMode()) } - } - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu { - Section { - Picker(selection: $showFinishedMatches) { - Text("Afficher tous les matchs").tag(true) - Text("Masquer les matchs terminés").tag(false) - } label: { + + Divider() + + 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") } - .labelsHidden() - .pickerStyle(.inline) - } header: { - Text("Option de filtrage") - } - Divider() + Divider() - Section { - Picker(selection: $filterOption) { - ForEach(PlanningFilterOption.allCases) { - Text($0.localizedPlanningLabel()).tag($0) + Section { + Picker(selection: $filterOption) { + ForEach(PlanningFilterOption.allCases) { + Text($0.localizedPlanningLabel()).tag($0) + } + } label: { + Text("Option de triage") } - } label: { + .labelsHidden() + .pickerStyle(.inline) + } header: { Text("Option de triage") - } - .labelsHidden() - .pickerStyle(.inline) - } header: { - Text("Option de triage") + } + } label: { + Label("Trier", systemImage: "line.3.horizontal.decrease.circle") + .symbolVariant( + filterOption == .byCourt || showFinishedMatches ? .fill : .none) } - } label: { - Label("Trier", systemImage: "line.3.horizontal.decrease.circle") - .symbolVariant( - filterOption == .byCourt || showFinishedMatches ? .fill : .none) - } - - Button("Mettre à jour", systemImage: "arrow.trianglehead.2.clockwise.rotate.90.circle") { - let now = Date() - matches.forEach { - if let startDate = $0.startDate, startDate > now { - $0.plannedStartDate = $0.startDate + Divider() + + Button("Mettre à jour", systemImage: "arrow.trianglehead.2.clockwise.rotate.90.circle") { + let now = Date() + matches.forEach { + if let startDate = $0.startDate, startDate > now { + $0.plannedStartDate = $0.startDate + } + } + + let groupByTournaments = matches.grouped { match in + match.currentTournament() + } + groupByTournaments.forEach { tournament, matches in + tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) } } - - let groupByTournaments = matches.grouped { match in - match.currentTournament() - } - groupByTournaments.forEach { tournament, matches in - tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) - } + .popoverTip(updatePlannedDatesTip) + + + } label: { + LabelOptions() } - .popoverTip(updatePlannedDatesTip) } } }) @@ -235,8 +239,8 @@ struct PlanningView: View { @Environment(\.editMode) private var editMode @State private var selectedIds = Set() @State private var showDateUpdateView: Bool = false - @State private var dateToUpdate: Date = Date() - + @State private var matchesToUpdate: [Match] = [] + let days: [Date] let keys: [Date] let timeSlots: [Date: [Match]] @@ -258,28 +262,34 @@ struct PlanningView: View { day: day, keys: keys.filter({ $0.dayInt == day.dayInt }), timeSlots: timeSlots, - selectedDay: selectedDay + selectedDay: selectedDay, + selectedIds: $selectedIds, + matchesForUpdateSheet: $matchesToUpdate ) } } } .toolbar(content: { if editMode?.wrappedValue == .active { - ToolbarItem(placement: .bottomBar) { + ToolbarItem(placement: .topBarTrailing) { Button { - showDateUpdateView = true + matchesToUpdate = matches.filter({ selectedIds.contains($0.stringId) }) } label: { - Text("Modifier la date des matchs sélectionnés") + Text("Modifier") } + .buttonStyle(.borderless) .disabled(selectedIds.isEmpty) } } }) + .onChange(of: matchesToUpdate, { oldValue, newValue in + showDateUpdateView = matchesToUpdate.count > 0 + }) .sheet(isPresented: $showDateUpdateView, onDismiss: { selectedIds.removeAll() + matchesToUpdate = [] }) { - let selectedMatches = matches.filter({ selectedIds.contains($0.stringId) }) - DateUpdateView(selectedMatches: selectedMatches) + DateUpdateView(selectedMatches: matchesToUpdate) } } } @@ -290,6 +300,7 @@ struct PlanningView: View { let selectedMatches: [Match] let selectedFormats: [MatchFormat] @State private var dateToUpdate: Date + @State private var updateStep: Int = 0 init(selectedMatches: [Match]) { self.selectedMatches = selectedMatches @@ -306,13 +317,37 @@ struct PlanningView: View { DatePicker(selection: $dateToUpdate) { Text(dateToUpdate.formatted(.dateTime.weekday(.wide))).font(.headline) } + + RowButtonView("Définir le nouvel horaire", role: .destructive) { + _setDate() + } + } footer: { + Text("Choisir un nouvel horaire pour tous les matchs sélectionnés") } - + Section { - DateAdjusterView(date: $dateToUpdate) - DateAdjusterView(date: $dateToUpdate, time: 10) + LabeledContent { + StepperView(title: "minutes", count: $updateStep, step: 5) + } label: { + Text("Décalage") + } + + RowButtonView("Décaler les horaires", role: .destructive) { + _updateDate() + } + + } footer: { + Text("décale CHAQUE horaire du nombre de minutes indiqué") + .foregroundStyle(.logoRed) + } + + VStack { + StepAdjusterView(step: $updateStep) + Divider() + StepAdjusterView(step: $updateStep, time: 10) ForEach(selectedFormats, id: \.self) { matchFormat in - DateAdjusterView(date: $dateToUpdate, matchFormat: matchFormat) + Divider() + StepAdjusterView(step: $updateStep, matchFormat: matchFormat) } } @@ -325,7 +360,10 @@ struct PlanningView: View { } } - .navigationTitle("Modification de la date") + .onChange(of: updateStep, { oldValue, newValue in + dateToUpdate.addTimeInterval(TimeInterval((newValue - oldValue) * 60)) + }) + .navigationTitle("Modifier l'horaire") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) .toolbar(content: { @@ -334,25 +372,34 @@ struct PlanningView: View { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { - Button("Valider") { - _updateDate() - } - } }) } } private func _updateDate() { + selectedMatches.forEach { match in + if match.hasStarted() || match.hasEnded() { + match.plannedStartDate?.addTimeInterval(TimeInterval(updateStep * 60)) + } else { + match.startDate?.addTimeInterval(TimeInterval(updateStep * 60)) + } + } + + let groupByTournaments = selectedMatches.grouped { match in + match.currentTournament() + } + groupByTournaments.forEach { tournament, matches in + tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + } + dismiss() + } + + private func _setDate() { selectedMatches.forEach { match in if match.hasStarted() || match.hasEnded() { match.plannedStartDate = dateToUpdate } else { - let hasStarted = match.currentTournament()?.hasStarted() == true match.startDate = dateToUpdate - if hasStarted { - match.plannedStartDate = dateToUpdate - } } } @@ -378,7 +425,10 @@ struct PlanningView: View { let keys: [Date] let timeSlots: [Date: [Match]] let selectedDay: Date? - + @Binding var selectedIds: Set + @State private var selectAll: Bool = false + @Binding var matchesForUpdateSheet: [Match] + var body: some View { Section { ForEach(keys, id: \.self) { key in @@ -386,12 +436,22 @@ struct PlanningView: View { key: key, matches: timeSlots[key]?.sorted( by: filterOption == .byDefault - ? \.computedOrder : \.courtIndexForSorting) ?? [] + ? \.computedOrder : \.courtIndexForSorting) ?? [], matchesForUpdateSheet: $matchesForUpdateSheet ) } .onMove(perform: enableMove ? moveSection : nil) } header: { - HeaderView(day: day, timeSlots: timeSlots) + if editMode?.wrappedValue == .active { + HStack { + Spacer() + FooterButtonView(selectAll ? "Tout desélectionner" : "Tout sélectionner") { + selectAll.toggle() + } + .textCase(nil) + } + } else { + HeaderView(day: day, timeSlots: timeSlots) + } } footer: { VStack(alignment: .leading) { if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { @@ -403,6 +463,16 @@ struct PlanningView: View { CourtOptionsView(timeSlots: timeSlots, underlined: true) } } + .onChange(of: selectAll, { oldValue, newValue in + if oldValue == false, newValue == true { + selectedIds = Set(timeSlots.filter({ keys.contains($0.key) }).values.flatMap({ values in values.compactMap({ match in match.stringId }) })) + } else if oldValue == true, newValue == false { + selectedIds.removeAll() + } + }) + .onChange(of: editMode?.wrappedValue) { oldValue, newValue in + selectAll = false + } } func moveSection(from source: IndexSet, to destination: Int) { @@ -450,7 +520,7 @@ struct PlanningView: View { let matches: [Match] @State private var isExpanded: Bool = false - @State private var showDateUpdateView: Bool = false + @Binding var matchesForUpdateSheet: [Match] var body: some View { if !matches.isEmpty { @@ -462,21 +532,23 @@ struct PlanningView: View { } label: { TimeSlotHeaderView(key: key, matches: matches) } + .onChange(of: editMode?.wrappedValue, { oldValue, newValue in + if oldValue == .inactive, newValue == .active, isExpanded == false { + isExpanded = true + } else if oldValue == .active, newValue == .inactive, isExpanded == true { + isExpanded = false + } + }) .contextMenu { PlanningView.CourtOptionsView(timeSlots: [key: matches], underlined: false) Button { - showDateUpdateView = true + matchesForUpdateSheet = matches } label: { Text("Modifier la date") } } - .sheet(isPresented: $showDateUpdateView, onDismiss: { - }) { - PlanningView.DateUpdateView(selectedMatches: matches) - } - // .onChange(of: editMode?.wrappedValue) { // if editMode?.wrappedValue == .active, isExpanded == false { // isExpanded = true @@ -500,6 +572,7 @@ struct PlanningView: View { } label: { MatchRowView(match: match) } + .listRowView(isActive: match.hasStarted(), color: .green, hideColorVariation: true) } } } diff --git a/PadelClub/Views/Score/EditScoreView.swift b/PadelClub/Views/Score/EditScoreView.swift index 624f2c5..9a355aa 100644 --- a/PadelClub/Views/Score/EditScoreView.swift +++ b/PadelClub/Views/Score/EditScoreView.swift @@ -19,6 +19,7 @@ struct EditScoreView: View { @Environment(\.dismiss) private var dismiss @State private var firstTeamIsFirstScoreToEnter: Bool = true @State private var shouldEndMatch: Bool = false + @State private var walkoutPosition: TeamPosition? init(match: Match, confirmScoreEdition: Binding) { let matchDescriptor = MatchDescriptor(match: match) @@ -196,9 +197,20 @@ struct EditScoreView: View { Text("Terminer le match") } + + if shouldEndMatch { + Picker(selection: $walkoutPosition) { + Text("Non").tag(nil as TeamPosition?) + Text(matchDescriptor.teamLabelOne).tag(TeamPosition.one) + Text(matchDescriptor.teamLabelTwo).tag(TeamPosition.two) + } label: { + Text("Abandon") + } + } + RowButtonView("Confirmer") { if shouldEndMatch { - matchDescriptor.match?.setUnfinishedScore(fromMatchDescriptor: matchDescriptor) + matchDescriptor.match?.setUnfinishedScore(fromMatchDescriptor: matchDescriptor, walkoutPosition: walkoutPosition) } else { matchDescriptor.match?.updateScore(fromMatchDescriptor: matchDescriptor) } diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift index 66d7de0..abdb908 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift @@ -22,6 +22,7 @@ struct TournamentGeneralSettingsView: View { @State private var umpireCustomContact: String @State private var umpireCustomMailIsInvalid: Bool = false @State private var umpireCustomPhoneIsInvalid: Bool = false + @State private var showCurrencyPicker: Bool = false // New state for action sheet @FocusState private var focusedField: Tournament.CodingKeys? let priceTags: [Double] = [15.0, 20.0, 25.0] @@ -45,7 +46,7 @@ struct TournamentGeneralSettingsView: View { TournamentDatePickerView() TournamentDurationManagerView() LabeledContent { - TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.defaultCurrency())) + TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: tournament.defaultCurrency())) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity) @@ -60,10 +61,14 @@ struct TournamentGeneralSettingsView: View { } label: { Text("Inscription") + FooterButtonView("modifier la devise") { + showCurrencyPicker = true + } + .font(.footnote) } LabeledContent { - TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: Locale.defaultCurrency())) + TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: tournament.defaultCurrency())) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity) @@ -84,27 +89,62 @@ struct TournamentGeneralSettingsView: View { } if tournament.onlineRegistrationCanBeEnabled() { + let canEnableOnlinePayment = dataStore.user.canEnableOnlinePayment() + Section { - NavigationLink { - RegistrationSetupView(tournament: tournament) + // MARK: - Online Registration Row + LabeledContent { + if tournament.enableOnlineRegistration { + Text("Activée") + .foregroundStyle(.green) + .font(.headline) + } else { + Text("Désactivée") + .foregroundStyle(.logoRed) + .font(.headline) + } } label: { + Text("Inscription en ligne") + Text(tournament.getOnlineRegistrationStatus().statusLocalized()) + } + + // MARK: - Online Payment Row (Conditionally Visible) + if canEnableOnlinePayment { LabeledContent { - if tournament.enableOnlineRegistration { - Text("activée").foregroundStyle(.green) + if tournament.enableOnlinePayment { + Text("Activé") + .foregroundStyle(.green) .font(.headline) } else { - Text("désactivée").foregroundStyle(.logoRed) + Text("Désactivé") + .foregroundStyle(.logoRed) .font(.headline) } } label: { - Text("Accéder aux paramètres") - Text(tournament.getOnlineRegistrationStatus().statusLocalized()) + Text("Paiement en ligne") + Text(tournament.getPaymentStatus().statusLocalized()) } } + + // MARK: - Access Settings Row + NavigationLink { + RegistrationSetupView(tournament: tournament) + } label: { + Text("Accès aux réglages") + } + } header: { - Text("Inscription en ligne") + if canEnableOnlinePayment { + Text("Inscription et paiement en ligne") + } else { + Text("Inscription en ligne") + } } footer: { - Text("Paramétrez les possibilités d'inscription en ligne à votre tournoi via Padel Club") + if canEnableOnlinePayment { + Text("Paramétrez les possibilités d'inscription en ligne à votre tournoi via Padel Club") + } else { + Text("Paramétrez les possibilités d'inscription et paiement en ligne à votre tournoi via Padel Club") + } } } @@ -156,9 +196,14 @@ struct TournamentGeneralSettingsView: View { } footer: { FooterButtonView("Ajouter le prix de l'inscription") { tournamentInformation.append("\n" + tournament.entryFeeMessage) + _save() } } } + .sheet(isPresented: $showCurrencyPicker, content: { + CurrencySelectorView() + .environment(tournament) + }) .navigationBarBackButtonHidden(focusedField != nil) .toolbar(content: { if focusedField != nil { @@ -177,7 +222,7 @@ struct TournamentGeneralSettingsView: View { if focusedField == ._entryFee { if tournament.isFree() { ForEach(priceTags, id: \.self) { priceTag in - Button(priceTag.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) { + Button(priceTag.formatted(.currency(code: tournament.defaultCurrency()).precision(.fractionLength(0)))) { entryFee = priceTag tournament.entryFee = priceTag focusedField = nil @@ -195,7 +240,7 @@ struct TournamentGeneralSettingsView: View { } } else if focusedField == ._clubMemberFeeDeduction { ForEach(deductionTags, id: \.self) { deductionTag in - Button(deductionTag.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) { + Button(deductionTag.formatted(.currency(code: tournament.defaultCurrency()).precision(.fractionLength(0)))) { clubMemberFeeDeduction = deductionTag tournament.clubMemberFeeDeduction = deductionTag focusedField = nil @@ -291,6 +336,100 @@ struct TournamentGeneralSettingsView: View { } } + struct CurrencySelectorView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament + @Environment(\.dismiss) var dismiss + @State private var currencySearchText: String = "" + + func formatter(forCurrencyCode currencyCode: String) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode + return formatter + } + + struct CurrencyData: Identifiable { + let id: String + let name: String + let symbol: String + + init?(code: String) { + if let name = Locale.current.localizedString(forCurrencyCode: code) { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = code + self.symbol = formatter.currencySymbol ?? code + self.id = code + self.name = name + } else { + return nil + } + } + } + + let currencies : [CurrencyData] = Locale.Currency.isoCurrencies.sorted(by: { Locale.current.localizedString(forCurrencyCode: $0.identifier) ?? $0.identifier < Locale.current.localizedString(forCurrencyCode: $1.identifier) ?? $1.identifier }).compactMap { currency in + CurrencyData(code: currency.identifier) + } + + var currencyCode: Binding { + Binding { + tournament.defaultCurrency() + } set: { currency in + tournament.currencyCode = currency + dataStore.tournaments.addOrUpdate(instance: tournament) + dismiss() + } + } + + var filteredCurrencies: [CurrencyData] { + if currencySearchText.isEmpty { + return currencies + } else { + return currencies.filter { + $0.name.lowercased().contains(currencySearchText.lowercased()) + } + } + } + + var body: some View { + NavigationStack { + List(selection: currencyCode) { + Section { + LabeledContent { + Text(tournament.defaultCurrency()) + } label: { + Text("Devise utilisée du tournoi") + } + } header: { + Text("") + } + + Section { + ForEach(filteredCurrencies) { currency in + LabeledContent { + Text(currency.symbol) + } label: { + Text(currency.name) + } + .tag(currency.id) + } + } + } + .navigationBarTitle("Choisir une devise") + .searchable(text: $currencySearchText, placement: .navigationBarDrawer(displayMode: .always)) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Retour", role: .cancel) { + self.dismiss() + } + } + } + .toolbarBackground(.visible, for: .navigationBar) + } + } + } + private func _confirmUmpireMail() { umpireCustomMailIsInvalid = false if umpireCustomMail.isEmpty { diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index 9e06d95..a0729e0 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -115,7 +115,7 @@ final class ServerDataTests: XCTestCase { let tournament = Tournament(event: eventId, name: "RG Homme", startDate: Date(), endDate: nil, creationDate: Date(), isPrivate: false, groupStageFormat: MatchFormat.megaTie, roundFormat: MatchFormat.nineGames, loserRoundFormat: MatchFormat.nineGamesDecisivePoint, groupStageSortMode: GroupStageOrderingMode.snake, groupStageCount: 2, rankSourceDate: Date(), dayDuration: 5, teamCount: 3, teamSorting: TeamSortingType.rank, federalCategory: TournamentCategory.mix, federalLevelCategory: TournamentLevel.p1000, federalAgeCategory: FederalTournamentAge.a45, closedRegistrationDate: Date(), groupStageAdditionalQualified: 4, courtCount: 9, prioritizeClubMembers: true, qualifiedPerGroupStage: 1, teamsPerGroupStage: 2, entryFee: 30.0, additionalEstimationDuration: 5, isDeleted: true, publishTeams: true, publishSummons: true, publishGroupStages: true, publishBrackets: true, shouldVerifyGroupStage: true, shouldVerifyBracket: true, hideTeamsWeight: true, publishTournament: true, hidePointsEarned: true, publishRankings: true, loserBracketMode: .manual, initialSeedRound: 8, initialSeedCount: 4, accountIsRequired: false, licenseIsRequired: false, minimumPlayerPerTeam: 3, maximumPlayerPerTeam: 5, information: "Super", umpireCustomMail: "razmig@padelclub.app", umpireCustomContact: "Raz", umpireCustomPhone: "+33681598193", hideUmpireMail: true, hideUmpirePhone: true, disableRankingFederalRuling: true, teamCountLimit: false, enableOnlinePayment: false, onlinePaymentIsMandatory: false, enableOnlinePaymentRefund: false, refundDateLimit: nil, stripeAccountId: nil, enableTimeToConfirm: false, isCorporateTournament: false, isTemplate: false, publishProg: true, - showTeamsInProg: true + showTeamsInProg: true, currencyCode: "USD") ) @@ -190,6 +190,7 @@ final class ServerDataTests: XCTestCase { assert(t.isTemplate == tournament.isTemplate) assert(t.publishProg == tournament.publishProg) assert(t.showTeamsInProg == tournament.showTeamsInProg) + assert(t.currencyCode == tournament.currencyCode) } else { XCTFail("missing data") }