Laurent 1 year ago
commit 32447ef056
  1. 8
      PadelClub.xcodeproj/project.pbxproj
  2. 4
      PadelClub/Extensions/String+Extensions.swift
  3. 203
      PadelClub/Utils/CloudConvert.swift
  4. 4
      PadelClub/Utils/Patcher.swift
  5. 6
      PadelClub/Utils/URLs.swift
  6. 10
      PadelClub/ViewModel/SearchViewModel.swift
  7. 7
      PadelClub/Views/Club/ClubDetailView.swift
  8. 8
      PadelClub/Views/Navigation/Toolbox/ToolboxView.swift
  9. 11
      PadelClub/Views/Tournament/Screen/AddTeamView.swift
  10. 8
      PadelClub/Views/Tournament/Subscription/Guard.swift
  11. 5
      PadelClub/Views/Tournament/Subscription/SubscriptionView.swift
  12. 9
      PadelClub/Views/User/UserCreationView.swift

@ -1960,7 +1960,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
@ -1985,7 +1985,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.3; MARKETING_VERSION = 1.0.4;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
OTHER_SWIFT_FLAGS = "-Onone"; OTHER_SWIFT_FLAGS = "-Onone";
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;
@ -2010,7 +2010,7 @@
CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 2;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\"";
DEVELOPMENT_TEAM = BQ3Y44M3Q6; DEVELOPMENT_TEAM = BQ3Y44M3Q6;
@ -2033,7 +2033,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.3; MARKETING_VERSION = 1.0.4;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-function-bodies=5 -Xfrontend -warn-long-expression-type-checking=20 -Xfrontend -warn-long-function-bodies=50"; OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-function-bodies=5 -Xfrontend -warn-long-expression-type-checking=20 -Xfrontend -warn-long-function-bodies=50";
PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub;

@ -14,7 +14,7 @@ extension String {
} }
var trimmed: String { var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines) replaceCharactersFromSet(characterSet: .newlines).trimmingCharacters(in: .whitespacesAndNewlines)
} }
func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String { func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String {
@ -71,7 +71,7 @@ extension String {
// Join the first letters together into a string // Join the first letters together into a string
let result = String(firstLetters) let result = String(firstLetters)
return result return String(result.prefix(10))
} }
} }

@ -29,196 +29,65 @@ class CloudConvert {
static let manager = CloudConvert() static let manager = CloudConvert()
func uploadFile(_ url: URL) async throws -> String { func uploadFile(_ url: URL) async throws -> String {
let taskResponse = try await createJob(url) return try await createJob(url)
let uploadResponse = try await uploadFile(taskResponse, url: url)
var fileReady = false
while fileReady == false {
try await Task.sleep(nanoseconds: 3_000_000_000)
let progressResponse = try await checkFile(id: uploadResponse.data.id)
if progressResponse.data.step == "finish" && progressResponse.data.stepPercent == 100 {
fileReady = true
print("progressResponse.data.minutes", progressResponse.data.minutes)
}
}
let convertedFile = try await downloadConvertedFile(id: uploadResponse.data.id)
return convertedFile
} }
func createJob(_ url: URL) async throws -> TaskResponse { func createJob(_ url: URL) async throws -> String {
guard let taskURL = URL(string: "https://api.convertio.co/convert") else { let apiPath = "https://\(URLs.activationHost.rawValue)/utils/xls-to-csv/"
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert") guard let taskURL = URL(string: apiPath) else {
throw CloudConvertionError.urlNotFound(apiPath)
} }
var request: URLRequest = URLRequest(url: taskURL) var request: URLRequest = URLRequest(url: taskURL)
let parameters = """
{"apikey":"d97cf13ef6d163e5e386c381fc8d256f","input":"upload","file":"","filename":"","outputformat":"csv","options":""}
"""
let postData = parameters.data(using: .utf8)
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = postData
let task = try await URLSession.shared.data(for: request) // Create the boundary string for multipart/form-data
//print("tried: \(request.url)") let boundary = UUID().uuidString
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(TaskResponse.self, from: task.0) // Set the content type to multipart/form-data with the boundary
} request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
func uploadFile(_ response: TaskResponse, url: URL) async throws -> UploadResponse { // The file to upload
guard let uploadTaskURL = URL(string: "https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") else { let fileName = url.lastPathComponent
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") let fileURL = url
}
var uploadRequest: URLRequest = URLRequest(url: uploadTaskURL)
uploadRequest.httpMethod = "PUT"
let uploadTask = try await URLSession.shared.upload(for: uploadRequest, fromFile: url)
//print("tried: \(uploadRequest.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: uploadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(UploadResponse.self, from: uploadTask.0) // Construct the body of the request
} var body = Data()
func checkFile(id: String) async throws -> ProgressResponse { // Start the body with the boundary and content-disposition for the file
guard let taskURL = URL(string: "https://api.convertio.co/convert/\(id)/status") else { body.append("--\(boundary)\r\n".data(using: .utf8)!)
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/status") body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
} body.append("Content-Type: application/vnd.ms-excel\r\n\r\n".data(using: .utf8)!)
var request: URLRequest = URLRequest(url: taskURL)
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
//print("tried: \(request.url)") // Append the file data
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) { if let fileData = try? Data(contentsOf: fileURL) {
print("errorResponse.error", errorResponse.error) body.append(fileData)
throw CloudConvertionError.serviceError(errorResponse)
} }
return try JSONDecoder().decode(ProgressResponse.self, from: task.0) // End the body with the boundary
} body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
func downloadConvertedFile(id: String) async throws -> String { // Set the body of the request
// try await Task.sleep(nanoseconds: 3_000_000_000) request.httpBody = body
guard let downloadTaskURL = URL(string: "https://api.convertio.co/convert/\(id)/dl/base64") else { let (data, response) = try await URLSession.shared.data(for: request)
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/dl/base64")
}
var downloadRequest: URLRequest = URLRequest(url: downloadTaskURL)
downloadRequest.httpMethod = "GET"
let downloadTask = try await URLSession.shared.data(for: downloadRequest)
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: downloadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
//print("tried: \(downloadRequest.url)") // Check the response status code
let dataResponse = try JSONDecoder().decode(DataResponse.self, from: downloadTask.0) if let httpResponse = response as? HTTPURLResponse {
if let decodedData = Data(base64Encoded: dataResponse.data.content), let string = String(data: decodedData, encoding: .utf8) { print("Status code: \(httpResponse.statusCode)")
return string
} }
throw CloudConvertionError.unknownError // Convert the response data to a String
if let responseString = String(data: data, encoding: .utf8) {
return responseString
} else {
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide")
throw CloudConvertionError.serviceError(error)
}
} }
} }
// MARK: - DataResponse
struct DataResponse: Decodable {
let code: Int
let status: String
let data: DataDownloadClass
}
// MARK: - DataClass
struct DataDownloadClass: Decodable {
let id, encode, content: String
}
// MARK: - ErrorResponse // MARK: - ErrorResponse
struct ErrorResponse: Decodable { struct ErrorResponse: Decodable {
let code: Int let code: Int
let status, error: String let status, error: String
} }
// MARK: - TaskResponse
struct TaskResponse: Decodable {
let code: Int
let status: String
let data: DataClass
}
// MARK: - DataClass
struct DataClass: Decodable {
let id: String
}
// MARK: - ProgressResponse
struct ProgressResponse: Decodable {
let code: Int
let status: String
let data: ProgressDataClass
}
// MARK: - DataClass
struct ProgressDataClass: Decodable {
let id, step: String
let stepPercent: Int
let minutes: String
enum CodingKeys: String, CodingKey {
case id, step
case stepPercent = "step_percent"
case minutes
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
step = try container.decode(String.self, forKey: .step)
minutes = try container.decode(String.self, forKey: .minutes)
if let value = try? container.decode(String.self, forKey: .stepPercent) {
print(value)
stepPercent = Int(value) ?? 0
} else {
stepPercent = try container.decode(Int.self, forKey: .stepPercent)
}
}
}
// MARK: - Output
struct Output: Decodable {
let url: String
let size: String
}
// MARK: - UploadResponse
struct UploadResponse: Decodable {
let code: Int
let status: String
let data: UploadDataClass
}
// MARK: - DataClass
struct UploadDataClass: Decodable {
let id, file: String
let size: Int
}
extension URL {
var encodedLastPathComponent: String {
if #available(iOS 17.0, *) {
lastPathComponent
} else {
lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? lastPathComponent
}
}
}

@ -14,7 +14,7 @@ enum PatchError: Error {
enum Patch: String, CaseIterable { enum Patch: String, CaseIterable {
case alexisLeDu case alexisLeDu
case importDataFromDev case importDataFromDevToProd
var id: String { var id: String {
return "padelclub.app.patch.\(self.rawValue)" return "padelclub.app.patch.\(self.rawValue)"
@ -44,7 +44,7 @@ class Patcher {
fileprivate static func _applyPatch(_ patch: Patch) throws { fileprivate static func _applyPatch(_ patch: Patch) throws {
switch patch { switch patch {
case .alexisLeDu: self._patchAlexisLeDu() case .alexisLeDu: self._patchAlexisLeDu()
case .importDataFromDev: try self._importDataFromDev() case .importDataFromDevToProd: try self._importDataFromDev()
} }
} }

@ -9,9 +9,10 @@ import Foundation
enum URLs: String, Identifiable { enum URLs: String, Identifiable {
case activationHost = "xlr.alwaysdata.net" // xlr.alwaysdata.net case activationHost = "xlr.alwaysdata.net"
case main = "https://xlr.alwaysdata.net/" case main = "https://xlr.alwaysdata.net/"
case api = "https://xlr.alwaysdata.net/roads/" case api = "https://xlr.alwaysdata.net/roads/"
case subscriptions = "https://apple.co/2Th4vqI" case subscriptions = "https://apple.co/2Th4vqI"
case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/" case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/"
//case padelClub = "https://padelclub.app" //case padelClub = "https://padelclub.app"
@ -24,13 +25,14 @@ enum URLs: String, Identifiable {
case appDescription = "https://padelclub.app/download/" case appDescription = "https://padelclub.app/download/"
case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn" case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn"
case appStore = "https://apps.apple.com/app/padel-club/id6484163558" case appStore = "https://apps.apple.com/app/padel-club/id6484163558"
case eula = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
case privacy = "https://padelclub.app/terms-of-use"
var id: String { return self.rawValue } var id: String { return self.rawValue }
var url: URL { var url: URL {
return URL(string: self.rawValue)! return URL(string: self.rawValue)!
} }
} }
enum PageLink: String, Identifiable, CaseIterable { enum PageLink: String, Identifiable, CaseIterable {

@ -68,7 +68,10 @@ class SearchViewModel: ObservableObject, Identifiable {
var contentUnavailableMessage: String { var contentUnavailableMessage: String {
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."] var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."]
if tokens.isEmpty { if tokens.isEmpty {
message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois. Dans ce pas, Padel Club ne pourra pas le trouver.") message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver.")
if filterOption == .male {
message.append("Depuis août 2024, le classement fédérale disponible est limité aux 40.000 premiers joueurs. Si le joueur n'a pas encore assez de points pour être visible, Padel Club ne pourra pas non plus le trouver.")
}
} }
return message.joined(separator: "\n") return message.joined(separator: "\n")
} }
@ -152,8 +155,9 @@ class SearchViewModel: ObservableObject, Identifiable {
func orPredicate() -> NSPredicate? { func orPredicate() -> NSPredicate? {
var predicates : [NSPredicate] = [] var predicates : [NSPredicate] = []
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation let canonicalVersionWithoutPunctuation = searchText.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmed
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed
switch tokens.first { switch tokens.first {
case .none: case .none:
if canonicalVersionWithoutPunctuation.isEmpty == false { if canonicalVersionWithoutPunctuation.isEmpty == false {

@ -66,6 +66,11 @@ struct ClubDetailView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.focused($focusedField, equals: ._name) .focused($focusedField, equals: ._name)
.submitLabel( displayContext == .addition ? .next : .done) .submitLabel( displayContext == .addition ? .next : .done)
.onChange(of: club.name) {
if club.name.count > 50 {
club.name = String(club.name.prefix(50))
}
}
.onSubmit(of: .text) { .onSubmit(of: .text) {
if club.acronym.isEmpty { if club.acronym.isEmpty {
club.acronym = club.name.canonicalVersion.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).acronym() club.acronym = club.name.canonicalVersion.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).acronym()
@ -90,7 +95,7 @@ struct ClubDetailView: View {
.focused($focusedField, equals: ._acronym) .focused($focusedField, equals: ._acronym)
.submitLabel(.done) .submitLabel(.done)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
.onSubmit(of: .text) { .onChange(of: club.acronym) {
if club.acronym.count > 10 { if club.acronym.count > 10 {
club.acronym = String(club.acronym.prefix(10)) club.acronym = String(club.acronym.prefix(10))
} else if club.acronym.count == 0 { } else if club.acronym.count == 0 {

@ -187,6 +187,14 @@ struct ToolboxView: View {
} }
} }
Section {
Link(destination: URLs.privacy.url) {
Text("Politique de confidentialité")
}
Link(destination: URLs.eula.url) {
Text("Contrat d'utilisation")
}
}
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if didResetApiCalls { if didResetApiCalls {

@ -203,7 +203,10 @@ struct AddTeamView: View {
} }
static private func _pastePredicate(pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption) -> NSPredicate? { static private func _pastePredicate(pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption) -> NSPredicate? {
let text = pasteField.canonicalVersion let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
// Remove all characters that are not in the allowedCharacterSet
let text = pasteField.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmed
let textStrings: [String] = text.components(separatedBy: .whitespacesAndNewlines) let textStrings: [String] = text.components(separatedBy: .whitespacesAndNewlines)
let nonEmptyStrings: [String] = textStrings.compactMap { $0.isEmpty ? nil : $0 } let nonEmptyStrings: [String] = textStrings.compactMap { $0.isEmpty ? nil : $0 }
@ -232,6 +235,7 @@ struct AddTeamView: View {
let components = text.split(separator: " ").sorted() let components = text.split(separator: " ").sorted()
let pattern = components.joined(separator: ".*") let pattern = components.joined(separator: ".*")
print(text, pattern)
let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern) let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
orPredicates.append(canonicalFullNamePredicate) orPredicates.append(canonicalFullNamePredicate)
@ -438,7 +442,7 @@ struct AddTeamView: View {
ContentUnavailableView { ContentUnavailableView {
Label("Aucun résultat", systemImage: "person.2.slash") Label("Aucun résultat", systemImage: "person.2.slash")
} description: { } description: {
Text("Aucun joueur classé n'a été trouvé dans ce message.") 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: { } actions: {
RowButtonView("Créer un joueur non classé") { RowButtonView("Créer un joueur non classé") {
presentPlayerCreation = true presentPlayerCreation = true
@ -451,7 +455,8 @@ struct AddTeamView: View {
} else { } else {
Section { Section {
ForEach(fetchPlayers.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) })) { player in let sortedPlayers = fetchPlayers.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) })
ForEach(sortedPlayers) { player in
ImportedPlayerView(player: player).tag(player.license!) ImportedPlayerView(player: player).tag(player.license!)
} }
} header: { } header: {

@ -141,14 +141,6 @@ import LeStorage
var currentPlan: StoreItem? { var currentPlan: StoreItem? {
return .monthlyUnlimited return .monthlyUnlimited
// #if DEBUG
// return .monthlyUnlimited
// #else
if let currentBestPlan = self.currentBestPlan, let plan = StoreItem(rawValue: currentBestPlan.productID) {
return plan
}
return nil
// #endif
} }
func userFilteredPurchases() -> [StoreKit.Transaction] { func userFilteredPurchases() -> [StoreKit.Transaction] {

@ -162,7 +162,6 @@ struct SubscriptionView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.orange) .tint(.orange)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} footer: { } footer: {
if product.item.isConsumable == false { if product.item.isConsumable == false {
SubscriptionFooterView() SubscriptionFooterView()
@ -264,6 +263,10 @@ fileprivate struct ProductsSectionView: View {
} }
} header: { } header: {
Text("Sélectionnez une offre").foregroundStyle(Color(white: 0.8)) Text("Sélectionnez une offre").foregroundStyle(Color(white: 0.8))
} footer: {
let message = "Consulter notre [politique de confidentialité](\(URLs.privacy.rawValue)) et le [contrat d'utilisation](\(URLs.eula.rawValue)) de Padel Club."
Text(.init(message))
.foregroundStyle(.white)
} }
} }

@ -142,8 +142,15 @@ struct UserCreationFormView: View {
} }
Section { Section {
Link(destination: URLs.privacy.url) {
Text("Politique de confidentialité")
}
Link(destination: URLs.eula.url) {
Text("Contrat d'utilisation")
}
Toggle(isOn: self.$dataCollectAuthorized) { Toggle(isOn: self.$dataCollectAuthorized) {
Text("J'autorise XLR Sport, éditeur de Padel Club, à sauvegarder les données ci-dessus. XLR Sport s'engage à ne pas partager ces données.").font(.footnote) Text("J'accepte les conditions d'utilisations du service Padel Club proposé par XLR Sport").font(.footnote)
} }
} }

Loading…
Cancel
Save