You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Views/Tournament/Screen/AddTeamView.swift

832 lines
31 KiB

//
// AddTeamView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 15/07/2024.
//
import SwiftUI
import LeStorage
import CoreData
struct AddTeamView: View {
@Environment(\.dismiss) var dismiss
private var fetchRequest: FetchRequest<ImportedPlayer>
private var fetchPlayers: FetchedResults<ImportedPlayer> { fetchRequest.wrappedValue }
let tournament: Tournament
var cancelShouldDismiss: Bool = false
enum FocusField: Hashable {
case pasteField
}
@FocusState private var focusedField: FocusField?
@State private var searchField: String = ""
@State private var presentSearch: Bool = false
@State private var presentPlayerSearch: Bool = false
@State private var presentPlayerCreation: Bool = false
@State private var presentImportView: Bool = false
@State private var createdPlayers: Set<PlayerRegistration> = Set()
@State private var createdPlayerIds: Set<String> = Set()
@State private var editedTeam: TeamRegistration?
@State private var pasteString: String?
@State private var selectionSearchField: String?
@State private var autoSelect: Bool = false
@State private var presentationCount: Int = 0
@State private var confirmDuplicate: Bool = false
@State private var homonyms: [PlayerRegistration] = []
@State private var confirmHomonym: Bool = false
@State private var editableTextField: String = ""
@State private var textHeight: CGFloat = 100 // Default height
@State private var hitsForSearch: [Int: Int] = [:]
@State private var searchForHit: Int = 0
@State private var displayWarningNotEnoughCharacter: Bool = false
@State private var testMessageIndex: Int = 0
@State private var presentLocalMultiplayerSearch: Bool = false
var tournamentStore: TournamentStore? {
return self.tournament.tournamentStore
}
init(tournament: Tournament, pasteString: String? = nil, editedTeam: TeamRegistration? = nil) {
self.tournament = tournament
_editedTeam = .init(wrappedValue: editedTeam)
if let team = editedTeam {
var createdPlayers: Set<PlayerRegistration> = Set()
var createdPlayerIds: Set<String> = Set()
team.unsortedPlayers().forEach { player in
createdPlayers.insert(player)
createdPlayerIds.insert(player.id)
}
_createdPlayers = .init(wrappedValue: createdPlayers)
_createdPlayerIds = .init(wrappedValue: createdPlayerIds)
}
let request: NSFetchRequest<ImportedPlayer> = ImportedPlayer.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)]
request.fetchLimit = 1000
if let pasteString {
_pasteString = .init(wrappedValue: pasteString)
request.predicate = SearchViewModel.pastePredicate(pasteField: pasteString, mostRecentDate: tournament.rankSourceDate, filterOption: tournament.tournamentCategory.playerFilterOption)
_autoSelect = .init(wrappedValue: true)
_editableTextField = .init(wrappedValue: pasteString)
_textHeight = .init(wrappedValue: Self._calculateHeight(text: pasteString))
cancelShouldDismiss = true
}
fetchRequest = FetchRequest(fetchRequest: request, animation: .default)
}
var selectionLimit: Int {
if tournament.isAnimation() {
return -1
} else {
return tournament.significantPlayerCount() - _currentSelectionIds().count
}
}
var body: some View {
if let pasteString, pasteString.isEmpty == false, fetchPlayers.isEmpty == false {
computedBody
.searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher dans les résultats"))
} else {
computedBody
}
}
var computedBody: some View {
List(selection: $createdPlayerIds) {
_buildingTeamView()
}
.onReceive(fetchPlayers.publisher.count()) { receivedCount in // <-- here
if let pasteString, pasteString.isEmpty == false, count == 2, autoSelect == true {
fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }).forEach { player in
createdPlayerIds.insert(player.license!)
}
autoSelect = false
}
}
.overlay(alignment: .bottom) {
if displayWarningNotEnoughCharacter {
Text("2 lettres mininum")
.toastFormatted()
.animation(.easeInOut(duration: 2.0), value: displayWarningNotEnoughCharacter)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
displayWarningNotEnoughCharacter = false
}
}
}
}
.alert("Présence d'homonyme", isPresented: $confirmHomonym) {
Button("Créer l'équipe quand même") {
_createTeam(checkDuplicates: false, checkHomonym: false)
}
Button("Annuler", role: .cancel) {
confirmHomonym = false
}
} message: {
let plural : String = homonyms.count > 1 ? "nt chacun" : ""
Text(homonyms.map({ $0.playerLabel() }).joined(separator: ", ") + " possède" + plural + " au moins un homonyme dans la base fédérale, vérifiez bien leur licence.")
}
.alert("Cette équipe existe déjà", isPresented: $confirmDuplicate) {
Button("Créer l'équipe quand même") {
_createTeam(checkDuplicates: false, checkHomonym: true)
}
Button("Annuler", role: .cancel) {
confirmDuplicate = false
}
} message: {
Text("Cette équipe existe déjà dans votre liste d'inscription.")
}
.sheet(isPresented: $presentLocalMultiplayerSearch) {
NavigationStack {
SelectablePlayerListView(allowSelection: -1, isPresented: false, searchField: searchField, dataSet: .club, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in
players.forEach { player in
let newPlayer = PlayerRegistration(importedPlayer: player)
newPlayer.setComputedRank(in: tournament)
createdPlayers = Set<PlayerRegistration>()
createdPlayerIds = Set<String>()
createdPlayers.insert(newPlayer)
createdPlayerIds.insert(newPlayer.id)
_createTeam(checkDuplicates: false, checkHomonym: false)
}
} contentUnavailableAction: { searchViewModel in
presentLocalMultiplayerSearch = false
selectionSearchField = searchViewModel.searchText
}
}
.tint(.master)
}
.sheet(isPresented: $presentPlayerSearch) {
NavigationStack {
SelectablePlayerListView(allowSelection: selectionLimit, isPresented: true, searchField: searchField, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in
players.forEach { player in
let newPlayer = PlayerRegistration(importedPlayer: player)
newPlayer.setComputedRank(in: tournament)
createdPlayers.insert(newPlayer)
createdPlayerIds.insert(newPlayer.id)
}
} contentUnavailableAction: { searchViewModel in
presentPlayerSearch = false
selectionSearchField = searchViewModel.searchText
}
}
.tint(.master)
}
.sheet(isPresented: $presentPlayerCreation) {
PlayerPopoverView(sex: _addPlayerSex()) { p in
p.setComputedRank(in: tournament)
createdPlayers.insert(p)
createdPlayerIds.insert(p.id)
}
.tint(.master)
}
.sheet(item: $selectionSearchField, onDismiss: {
selectionSearchField = nil
}) { selectionSearchField in
PlayerPopoverView(source: selectionSearchField, sex: _addPlayerSex()) { p in
p.setComputedRank(in: tournament)
createdPlayers.insert(p)
createdPlayerIds.insert(p.id)
}
.tint(.master)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Annuler", role: .cancel) {
dismiss()
}
}
if pasteString == nil {
ToolbarItem(placement: .bottomBar) {
PasteButton(payloadType: String.self) { strings in
let first = strings.first ?? ""
handlePasteString(first)
}
.foregroundStyle(.master)
.labelStyle(.titleAndIcon)
.buttonBorderShape(.capsule)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
let generalString = UIPasteboard.general.string ?? ""
#if targetEnvironment(simulator)
let s = testMessages[testMessageIndex % testMessages.count]
handlePasteString(s)
testMessageIndex += 1
#else
handlePasteString(generalString)
#endif
} label: {
Label("Coller", systemImage: "doc.on.clipboard").labelStyle(.iconOnly)
}
.foregroundStyle(.master)
.labelStyle(.iconOnly)
.buttonBorderShape(.capsule)
}
}
.navigationBarBackButtonHidden(true)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(.automatic, for: .bottomBar)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(editedTeam == nil ? "Ajouter une équipe" : "Modifier l'équipe")
.environment(\.editMode, Binding.constant(EditMode.active))
}
private func _isEditingTeam() -> Bool {
createdPlayerIds.isEmpty == false || editedTeam != nil || pasteString != nil
}
var unsortedPlayers: [PlayerRegistration] {
tournament.unsortedPlayers()
}
@ViewBuilder
private func _managementView() -> some View {
Section {
RowButtonView("Ajouter via la base fédérale") {
presentPlayerSearch = true
}
} footer: {
if let rankSourceDate = tournament.rankSourceDate {
Text("Cherchez dans la base fédérale de \(rankSourceDate.monthYearFormatted), vous y trouverez tous les joueurs ayant participé à au moins un tournoi dans les 12 derniers mois.")
}
}
if tournament.isAnimation(), createdPlayers.isEmpty == true {
Section {
RowButtonView("Ajouter plusieurs joueurs du club") {
presentLocalMultiplayerSearch = true
}
} footer: {
Text("Crée une équipe par joueur sélectionné")
}
}
Section {
RowButtonView("Créer un non classé / non licencié") {
if let pasteString, pasteString.isEmpty == false {
selectionSearchField = pasteString
} else {
presentPlayerCreation = true
}
}
} footer: {
Text("Si le joueur n'a pas encore de licence ou n'a pas encore participé à une compétition, vous pouvez le créer vous-même.")
}
}
private func _addPlayerSex() -> Int {
switch tournament.tournamentCategory {
case .men, .unlisted:
return 1
case .women:
return 0
case .mix:
return 1
}
}
private func _filterOption() -> PlayerFilterOption {
return tournament.tournamentCategory.playerFilterOption
}
private func _currentSelection() -> Set<PlayerRegistration> {
var currentSelection = Set<PlayerRegistration>()
createdPlayerIds.compactMap { id in
fetchPlayers.first(where: { id == $0.license })
}.forEach { player in
let player = PlayerRegistration(importedPlayer: player)
player.setComputedRank(in: tournament)
currentSelection.insert(player)
}
createdPlayerIds.compactMap { id in
createdPlayers.first(where: { id == $0.id })
}.forEach {
currentSelection.insert($0)
}
return currentSelection
}
private func _currentSelectionIds() -> [String?] {
var currentSelection = [String?]()
createdPlayerIds.compactMap { id in
fetchPlayers.first(where: { id == $0.license })
}.forEach { player in
currentSelection.append(player.license)
}
createdPlayerIds.compactMap { id in
createdPlayers.first(where: { id == $0.id })
}.forEach {
currentSelection.append($0.licenceId)
}
return currentSelection
}
private func _isDuplicate() -> Bool {
if tournament.isAnimation() { return false }
let ids : [String?] = _currentSelectionIds()
if tournament.selectedSortedTeams().anySatisfy({ $0.containsExactlyPlayerLicenses(ids) }) {
return true
}
return false
}
private func _createTeam(checkDuplicates: Bool, checkHomonym: Bool) {
if checkDuplicates && _isDuplicate() {
confirmDuplicate = true
return
}
let players = _currentSelection()
if checkHomonym {
homonyms = players.filter({ $0.hasHomonym() })
if homonyms.isEmpty == false {
confirmHomonym = true
return
}
}
let team = tournament.addTeam(players)
self.tournamentStore?.teamRegistrations.addOrUpdate(instance: team)
self.tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players)
pasteString = nil
editableTextField = ""
createdPlayers.removeAll()
createdPlayerIds.removeAll()
if team.players().count > 1 {
dismiss()
} else {
editedTeam = team
team.unsortedPlayers().forEach { player in
createdPlayers.insert(player)
createdPlayerIds.insert(player.id)
}
}
}
private func _updateTeam(checkDuplicates: Bool) {
guard let editedTeam else { return }
if checkDuplicates && _isDuplicate() {
confirmDuplicate = true
return
}
let players = _currentSelection()
editedTeam.updatePlayers(players, inTournamentCategory: tournament.tournamentCategory)
self.tournamentStore?.teamRegistrations.addOrUpdate(instance: editedTeam)
self.tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players)
pasteString = nil
editableTextField = ""
if editedTeam.players().count > 1 {
dismiss()
}
}
// Calculating the height based on the content of the TextEditor
static private func _calculateHeight(text: String) -> CGFloat {
let size = CGSize(width: UIScreen.main.bounds.width - 32, height: .infinity)
let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 17)]
let boundingRect = text.boundingRect(
with: size,
options: .usesLineFragmentOrigin,
attributes: attributes,
context: nil
)
return max(boundingRect.height + 20, 40) // Add some padding and set a minimum height
}
struct PasteStringSection: View {
let pasteString: String?
@Binding var editableTextField: String
@Binding var textHeight: CGFloat
@FocusState var focusedField: AddTeamView.FocusField?
var handlePasteString: (String) -> Void
@Binding var displayWarningNotEnoughCharacter: Bool
var body: some View {
if let pasteString {
Section {
TextEditor(text: $editableTextField)
.frame(height: textHeight)
.onChange(of: editableTextField) {
textHeight = AddTeamView._calculateHeight(text: pasteString)
}
.focused($focusedField, equals: .pasteField)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button("Fermer", role: .cancel) {
self.editableTextField = pasteString
self.focusedField = nil
}
Spacer()
Button("Chercher") {
if editableTextField.count > 1 {
self.handlePasteString(editableTextField)
self.focusedField = nil
} else {
self.displayWarningNotEnoughCharacter = true
}
}
.buttonStyle(.bordered)
}
}
} header: {
Text("Contenu du presse-papier")
} footer: {
HStack {
FooterButtonView("éditer") {
self.focusedField = .pasteField
}
Spacer()
FooterButtonView("effacer le texte") {
self.focusedField = nil
self.editableTextField = ""
self.handlePasteString("")
}
}
}
}
}
}
@ViewBuilder
private func _buildingTeamView() -> some View {
PasteStringSection(
pasteString: pasteString,
editableTextField: $editableTextField,
textHeight: $textHeight,
focusedField: _focusedField,
handlePasteString: handlePasteString,
displayWarningNotEnoughCharacter: $displayWarningNotEnoughCharacter
)
TeamSelectionSection(
createdPlayerIds: createdPlayerIds,
createdPlayers: createdPlayers,
unsortedPlayers: unsortedPlayers,
fetchPlayers: fetchPlayers,
editedTeam: editedTeam,
pasteString: pasteString,
tournament: tournament,
_createTeam: _createTeam,
_updateTeam: _updateTeam,
dismiss: dismiss,
_currentSelection: _currentSelection
)
if let pasteString, pasteString.isEmpty == false {
let sortedPlayers = _searchFilteredPlayers()
if sortedPlayers.isEmpty {
ContentUnavailableView {
Label("Aucun résultat", systemImage: "person.2.slash")
} description: {
Text("Aucun joueur classé n'a été trouvé dans ce message. Attention, si un joueur n'a pas joué de tournoi dans les 12 derniers, Padel Club ne pourra pas le trouver.")
} actions: {
RowButtonView("Créer un joueur non classé") {
selectionSearchField = pasteString
}
RowButtonView("Chercher dans la base") {
presentPlayerSearch = true
}
RowButtonView("Effacer cette recherche") {
self.pasteString = nil
self.editableTextField = ""
}
}
} else {
_listOfPlayers(searchFilteredPlayers: sortedPlayers, pasteString: pasteString)
}
} else {
_managementView()
}
}
//
// if let pasteString, pasteString.isEmpty == false {
// let sortedPlayers = _searchFilteredPlayers()
//
// if sortedPlayers.isEmpty {
// ContentUnavailableView {
// Label("Aucun résultat", systemImage: "person.2.slash")
// } description: {
// Text("Aucun joueur classé n'a été trouvé dans ce message. Attention, si un joueur n'a pas joué de tournoi dans les 12 derniers, Padel Club ne pourra pas le trouver.")
// } actions: {
// RowButtonView("Créer un joueur non classé") {
// selectionSearchField = pasteString
// }
//
// RowButtonView("Chercher dans la base") {
// presentPlayerSearch = true
// }
//
// RowButtonView("Effacer cette recherche") {
// self.pasteString = nil
// self.editableTextField = ""
// }
// }
//
// } else {
// _listOfPlayers(searchFilteredPlayers: sortedPlayers, pasteString: pasteString)
// }
// } else {
// _managementView()
// }
// }
@MainActor
func hitForSearch(_ ip: ImportedPlayer, _ pasteString: String?) -> Int {
guard let pasteString else { return 0 }
let _searchForHit = pasteString.hashValue
if searchForHit != _searchForHit {
DispatchQueue.main.async {
searchForHit = _searchForHit
hitsForSearch = [:]
}
}
let value = hitsForSearch[ip.id.hashValue]
if let value {
return value
} else {
let hit = ip.hitForSearch(pasteString)
DispatchQueue.main.async {
hitsForSearch[ip.id.hashValue] = hit
}
return hit
}
}
private var count: Int {
return fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.count
}
private var hitTarget: Int {
if (pasteString?.matches(of: /[1-9][0-9]{5,7}/).count ?? 0) > 1 {
if fetchPlayers.filter({ hitForSearch($0, pasteString) == 100 }).count == 2 { return 100 }
} else {
return 2
}
return 1
}
private func handlePasteString(_ first: String) {
if first.isEmpty == false {
fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption())
fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)]
autoSelect = true
}
pasteString = first
editableTextField = first
textHeight = Self._calculateHeight(text: first)
}
@ViewBuilder
private func _listOfPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> some View {
let sortedPlayers = _sortedPlayers(searchFilteredPlayers: searchFilteredPlayers, pasteString: pasteString)
Section {
ForEach(sortedPlayers) { player in
ImportedPlayerView(player: player).tag(player.license!)
//Text(player.getLastName() + " " + player.getFirstName()).tag(player.license!)
}
} header: {
Text(sortedPlayers.count.formatted() + " résultat" + sortedPlayers.count.pluralSuffix)
}
}
private func _searchFilteredPlayers() -> [ImportedPlayer] {
if searchField.isEmpty {
return Array(fetchPlayers)
} else {
return fetchPlayers.filter({ $0.contains(searchField) })
}
}
private func _sortedPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> [ImportedPlayer] {
return searchFilteredPlayers.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) })
}
}
struct TeamSelectionSection: View {
let createdPlayerIds: Set<String>
let createdPlayers: Set<PlayerRegistration>
let unsortedPlayers: [PlayerRegistration]
let fetchPlayers: FetchedResults<ImportedPlayer>
let editedTeam: TeamRegistration?
let pasteString: String?
let tournament: Tournament
let _createTeam: (Bool, Bool) -> Void
let _updateTeam: (Bool) -> Void
let dismiss: DismissAction
let _currentSelection: () -> Set<PlayerRegistration>
var body: some View {
Section {
PlayerListView(createdPlayerIds: createdPlayerIds,
createdPlayers: createdPlayers,
unsortedPlayers: unsortedPlayers,
fetchPlayers: fetchPlayers,
editedTeam: editedTeam,
pasteString: pasteString,
tournament: tournament)
ActionButton(editedTeam: editedTeam,
createdPlayerIds: createdPlayerIds,
_createTeam: _createTeam,
_updateTeam: _updateTeam,
dismiss: dismiss)
} header: {
TeamHeader(tournament: tournament,
_currentSelection: _currentSelection)
}
}
}
struct PlayerListView: View {
let createdPlayerIds: Set<String>
let createdPlayers: Set<PlayerRegistration>
let unsortedPlayers: [PlayerRegistration]
let fetchPlayers: FetchedResults<ImportedPlayer>
let editedTeam: TeamRegistration?
let pasteString: String?
let tournament: Tournament
var body: some View {
ForEach(createdPlayerIds.sorted(), id: \.self) { id in
if let p = createdPlayers.first(where: { $0.id == id }) {
CreatedPlayerView(player: p, unsortedPlayers: unsortedPlayers, editedTeam: editedTeam, tournament: tournament)
}
if let p = fetchPlayers.first(where: { $0.license == id }) {
FetchedPlayerView(player: p, unsortedPlayers: unsortedPlayers, pasteString: pasteString, tournament: tournament)
}
}
}
}
struct CreatedPlayerView: View {
let player: PlayerRegistration
let unsortedPlayers: [PlayerRegistration]
let editedTeam: TeamRegistration?
let tournament: Tournament
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let existingPlayer = unsortedPlayers.first(where: { ($0.licenceId == player.licenceId && $0.licenceId != nil) }), editedTeam?.includes(player: existingPlayer) == false {
Text("Déjà inscrit !").foregroundStyle(.logoRed).bold()
}
if tournament.isPlayerAgeInadequate(player: player) {
Text("Âge invalide !").foregroundStyle(.logoRed).bold()
}
if tournament.isPlayerRankInadequate(player: player) {
Text("Trop bien classé !").foregroundStyle(.logoRed).bold()
}
PlayerView(player: player).tag(player.id)
.environment(tournament)
}
}
}
struct FetchedPlayerView: View {
let player: ImportedPlayer
let unsortedPlayers: [PlayerRegistration]
let pasteString: String?
let tournament: Tournament
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let pasteString, pasteString.isEmpty == false, unsortedPlayers.first(where: { $0.licenceId == player.license }) != nil {
Text("Déjà inscrit !").foregroundStyle(.logoRed).bold()
}
if tournament.isPlayerAgeInadequate(player: player) {
Text("Âge invalide !").foregroundStyle(.logoRed).bold()
}
if tournament.isPlayerRankInadequate(player: player) {
Text("Trop bien classé !").foregroundStyle(.logoRed).bold()
}
ImportedPlayerView(player: player).tag(player.license!)
}
}
}
struct ActionButton: View {
let editedTeam: TeamRegistration?
let createdPlayerIds: Set<String>
let _createTeam: (Bool, Bool) -> Void
let _updateTeam: (Bool) -> Void
let dismiss: DismissAction
var body: some View {
if editedTeam == nil {
if createdPlayerIds.isEmpty {
RowButtonView("Bloquer une place") {
_createTeam(false, false)
}
} else {
RowButtonView("Ajouter l'équipe") {
_createTeam(true, true)
}
}
} else {
RowButtonView("Confirmer") {
_updateTeam(false)
dismiss()
}
}
}
}
struct TeamHeader: View {
let tournament: Tournament
let _currentSelection: () -> Set<PlayerRegistration>
var body: some View {
let currentSelection = _currentSelection()
let selectedSortedTeams = tournament.selectedSortedTeams()
let rank = currentSelection.map { $0.computedRank }.reduce(0, +)
let teamIndex = selectedSortedTeams.firstIndex(where: { $0.weight >= rank }) ?? selectedSortedTeams.count
if !currentSelection.isEmpty, !tournament.hideWeight(), rank > 0 {
HStack(spacing: 16.0) {
VStack(alignment: .leading, spacing: 0) {
Text("Rang").font(.caption)
Text("#" + (teamIndex + 1).formatted())
}
VStack(alignment: .leading, spacing: 0) {
Text("Poids").font(.caption)
Text(rank.formatted())
}
Spacer()
VStack(alignment: .trailing, spacing: 0) {
Text("").font(.caption)
Text(tournament.cutLabel(index: teamIndex, teamCount: selectedSortedTeams.count))
}
}
}
}
}
let testMessages = [
"Anthony dovetta ( 3620578 K )et christophe capeau ( 4666443v)",
"""
ok merci, il s'agit de :
Olivier Seguin - licence 5033439
JPascal Bondierlange - licence :
6508359 С
Cordialement
""",
"""
Bonsoir Lise, peux tu nous inscrire pour le 250 hommes du 15 au 17 novembre ?
Paires DESCHAMPS/PARDO. En te remerciant. Bonne soirée
Franck
""",
"""
Coucou inscription pour le tournoi du 11 /
12 octobre
Dumoutier/ Liagre Charlotte
Merci de ta confirmation"
""",
"""
Anthony Contet 6081758f
Tullou Benjamin 8990867f
""",
"""
Sms Julien La Croix +33622886688
Salut Raz, c'est ! Ju Lacroix J'espère que tu vas bien depuis le temps! Est-ce que tu peux nous inscrire au 1000 de Bandol avec Derek Gerson stp?
"""
]