parent
cb89a322ca
commit
13e09d2163
@ -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) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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() |
||||
} |
||||
@ -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<Bool> { |
||||
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 |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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() |
||||
} |
||||
@ -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() |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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()) |
||||
} |
||||
} |
||||
} |
||||
@ -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<Bool> { |
||||
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) |
||||
} |
||||
} |
||||
} |
||||
@ -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()) |
||||
} |
||||
Loading…
Reference in new issue