diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 8e865a1..d292c2f 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -497,6 +497,7 @@ FF8F264E2BAE0B9600650388 /* MatchTypeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchTypeSelectionView.swift; sourceTree = ""; }; FF8F26502BAE0BAD00650388 /* MatchFormatPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchFormatPickerView.swift; sourceTree = ""; }; FF8F26532BAE1E4400650388 /* TableStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableStructureView.swift; sourceTree = ""; }; + FF92660F2C255E4A002361A4 /* PadelClub.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PadelClub.entitlements; sourceTree = ""; }; FF9267F72BCE78C70080F940 /* CashierView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashierView.swift; sourceTree = ""; }; FF9267F92BCE78EB0080F940 /* CashierDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashierDetailView.swift; sourceTree = ""; }; FF9267FB2BCE84870080F940 /* PlayerPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPayView.swift; sourceTree = ""; }; @@ -638,6 +639,7 @@ C425D3FF2B6D249D002A7B48 /* PadelClub */ = { isa = PBXGroup; children = ( + FF92660F2C255E4A002361A4 /* PadelClub.entitlements */, FFE2D2D72C216D4800D0C7BE /* GoogleService-Info.plist */, FF0CA5742BDA4AE10080E843 /* PrivacyInfo.xcprivacy */, FFA6D78A2BB0BEB3003A31F3 /* Info.plist */, @@ -1900,6 +1902,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 60; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -1940,6 +1943,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 60; DEFINES_MODULE = YES; diff --git a/PadelClub/Data/AppSettings.swift b/PadelClub/Data/AppSettings.swift index da30d59..89a5763 100644 --- a/PadelClub/Data/AppSettings.swift +++ b/PadelClub/Data/AppSettings.swift @@ -13,12 +13,23 @@ import SwiftUI class AppSettings: MicroStorable { var lastDataSource: String? = nil + var didCreateAccount: Bool = false + var didRegisterAccount: Bool = false required init() { } - + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lastDataSource = try container.decodeIfPresent(String.self, forKey: ._lastDataSource) + didCreateAccount = try container.decodeIfPresent(Bool.self, forKey: ._didCreateAccount) ?? false + didRegisterAccount = try container.decodeIfPresent(Bool.self, forKey: ._didRegisterAccount) ?? false + } + enum CodingKeys: String, CodingKey { case _lastDataSource = "lastDataSource" + case _didCreateAccount = "didCreateAccount" + case _didRegisterAccount = "didRegisterAccount" } } diff --git a/PadelClub/Data/Club.swift b/PadelClub/Data/Club.swift index 0428a90..6b873b3 100644 --- a/PadelClub/Data/Club.swift +++ b/PadelClub/Data/Club.swift @@ -216,8 +216,6 @@ extension Club { } func hasBeenCreated(by creatorId: String?) -> Bool { - guard let creatorId else { return false } - guard let creator else { return false } return creatorId == creator } diff --git a/PadelClub/Info.plist b/PadelClub/Info.plist index 9dcd617..40756ec 100644 --- a/PadelClub/Info.plist +++ b/PadelClub/Info.plist @@ -18,6 +18,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + app.padelclub + CFBundleURLSchemes + + padelclub + + + ITSAppUsesNonExemptEncryption UIFileSharingEnabled diff --git a/PadelClub/PadelClub.entitlements b/PadelClub/PadelClub.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/PadelClub/PadelClub.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/PadelClub/PadelClubApp.swift b/PadelClub/PadelClubApp.swift index 3bd835b..aaa0e94 100644 --- a/PadelClub/PadelClubApp.swift +++ b/PadelClub/PadelClubApp.swift @@ -15,7 +15,33 @@ struct PadelClubApp: App { let persistenceController = PersistenceController.shared @State private var navigationViewModel = NavigationViewModel() @StateObject var networkMonitor: NetworkMonitor = NetworkMonitor() + @StateObject var dataStore = DataStore.shared @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @State private var registrationError: RegistrationError? = nil + + var presentError: Binding { + Binding { + registrationError != nil + } set: { value in + if value == false { + registrationError = nil + } + } + } + + enum RegistrationError: LocalizedError { + case badActivationLink + case activationFailed + + var errorDescription: String? { + switch self { + case .badActivationLink: + return "Le lien d'activation n'a pas fonctionné" + case .activationFailed: + return "L'activation de votre compte n'a pas fonctionné" + } + } + } static var appVersion: String { let dictionary = Bundle.main.infoDictionary! @@ -27,7 +53,19 @@ struct PadelClubApp: App { var body: some Scene { WindowGroup { MainView() + .alert(isPresented: presentError, error: registrationError) { + Button("Contactez-nous") { + _openMail() + } + Button("Annuler", role: .cancel) { + registrationError = nil + } + } + .onOpenURL { url in + _handleIncomingURL(url) + } .environmentObject(networkMonitor) + .environmentObject(dataStore) .environment(navigationViewModel) .accentColor(.master) .onAppear { @@ -47,11 +85,65 @@ struct PadelClubApp: App { } } + private func _handleIncomingURL(_ url: URL) { + // Parse the URL + let pathComponents = url.pathComponents + if url.scheme == "padelclub" && pathComponents.count > 3 && pathComponents[1] == "activate" { + let uidb64 = pathComponents[2] + let token = pathComponents[3] + _activateUser(uidb64: uidb64, token: token) + print(uidb64, token) + } else { + // Handle invalid URL case + print("Handle invalid URL case") + registrationError = .badActivationLink + } + } + + private func _activateUser(uidb64: String, token: String) { + guard let url = URL(string: "https://\(URLs.activationHost.rawValue)/activate/\(uidb64)/\(token)/") else { + registrationError = .activationFailed + return + } + + URLSession.shared.dataTask(with: url) { (data, response, error) in + guard let data = data, let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + print("activation error") + print(error) + registrationError = .activationFailed + return + } + + DispatchQueue.main.async { + dataStore.appSettings.didRegisterAccount = true + dataStore.appSettingsStorage.write() + + if navigationViewModel.selectedTab != .umpire { + navigationViewModel.selectedTab = .umpire + } + + if navigationViewModel.umpirePath.isEmpty { + navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login) + } else if navigationViewModel.umpirePath.last! != .login { + navigationViewModel.umpirePath.removeAll() + navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login) + } + } + }.resume() + } + fileprivate func _onAppear() { let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] Logger.log("doc dir = \(docURL.absoluteString)") UserDefaults.standard.set(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable") } + + + private func _openMail(emailTo: String = "support@padelclub.app", subject: String = "Support Padel Club") { + if let url = URL(string: "mailto:\(emailTo)?subject=\(subject)"), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } } class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/PadelClub/Utils/URLs.swift b/PadelClub/Utils/URLs.swift index 782fbe4..e6aa0de 100644 --- a/PadelClub/Utils/URLs.swift +++ b/PadelClub/Utils/URLs.swift @@ -8,6 +8,7 @@ import Foundation enum URLs: String, Identifiable { + case activationHost = "xlr.alwaysdata.net" case subscriptions = "https://apple.co/2Th4vqI" case main = "https://xlr.alwaysdata.net/" case api = "https://xlr.alwaysdata.net/roads/" diff --git a/PadelClub/ViewModel/NavigationViewModel.swift b/PadelClub/ViewModel/NavigationViewModel.swift index b4cc277..ce05d89 100644 --- a/PadelClub/ViewModel/NavigationViewModel.swift +++ b/PadelClub/ViewModel/NavigationViewModel.swift @@ -11,7 +11,7 @@ import SwiftUI class NavigationViewModel { var path = NavigationPath() var toolboxPath = NavigationPath() - var umpirePath = NavigationPath() + var umpirePath: [UmpireView.UmpireScreen] = [] var ongoingPath = NavigationPath() var selectedTab: TabDestination? var agendaDestination: AgendaDestination? = .activity diff --git a/PadelClub/Views/Club/ClubDetailView.swift b/PadelClub/Views/Club/ClubDetailView.swift index 61c0d37..69841fb 100644 --- a/PadelClub/Views/Club/ClubDetailView.swift +++ b/PadelClub/Views/Club/ClubDetailView.swift @@ -18,6 +18,7 @@ struct ClubDetailView: View { @State private var selectedCourt: Court? @Bindable var club: Club @State private var clubDeleted: Bool = false + @State private var confirmDeletion: Bool = false var displayContext: DisplayContext var selection: ((Club) -> ())? = nil @@ -69,6 +70,8 @@ struct ClubDetailView: View { } } + .disabled(displayContext == .lockedForEditing) + LabeledContent { if acronymMode == .automatic || displayContext == .lockedForEditing { Text(club.acronym) @@ -134,7 +137,6 @@ struct ClubDetailView: View { if displayContext == .addition { focusedField = ._zipCode } - club.city = city } } label: { Text("Ville") @@ -151,9 +153,6 @@ struct ClubDetailView: View { .frame(maxWidth: .infinity) .focused($focusedField, equals: ._zipCode) .submitLabel( displayContext == .addition ? .next : .done) - .onSubmit { - club.zipCode = zipCode - } } label: { Text("Code Postal") } @@ -179,7 +178,7 @@ struct ClubDetailView: View { Text("Accéder au club sur Tenup") } } - if let padelClubLink { + if let padelClubLink, club.creator != nil { Link(destination: padelClubLink) { Text("Accéder au club sur Padel Club") } @@ -216,16 +215,8 @@ struct ClubDetailView: View { if displayContext == .edition { Section { - RowButtonView("Supprimer ce club", role: .destructive) { - do { - clubDeleted = true - dataStore.user.clubs.removeAll(where: { $0 == club.id }) - self.dataStore.saveUser() - try dataStore.clubs.deleteById(club.id) - dismiss() - } catch { - Logger.error(error) - } + RowButtonView("Supprimer ce club", role: .destructive, confirmationMessage: "Tous les tournois liés à ce club seront supprimés") { + _deleteClub() } } } @@ -241,6 +232,12 @@ struct ClubDetailView: View { .navigationDestination(item: $selectedCourt) { court in CourtView(court: court) } + .onChange(of: zipCode) { + club.zipCode = zipCode + } + .onChange(of: city) { + club.city = city + } .onDisappear { if displayContext == .edition && clubDeleted == false { do { @@ -257,6 +254,39 @@ struct ClubDetailView: View { } } } + .toolbar { + if displayContext == .edition { + ToolbarItem(placement: .topBarTrailing) { + Button(role: .destructive) { + confirmDeletion = true + } label: { + LabelDelete() + } + } + } + } + .confirmationDialog("Êtes-vous sûr de vouloir supprimer ce club ?", isPresented: $confirmDeletion, titleVisibility: .visible) { + Button("Oui, je suis sûr") { + _deleteClub() + } + + Button("Annuler", role: .cancel) { + } + } message: { + Text("Tous les tournois liés à ce club seront supprimés") + } + } + + private func _deleteClub() { + do { + clubDeleted = true + dataStore.user.clubs.removeAll(where: { $0 == club.id }) + self.dataStore.saveUser() + try dataStore.clubs.deleteById(club.id) + dismiss() + } catch { + Logger.error(error) + } } } diff --git a/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift b/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift index 6385698..cd6ec8a 100644 --- a/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift +++ b/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift @@ -51,6 +51,12 @@ struct ClubCourtSetupView: View { ForEach((0.. some View { let court = tournamentClub.customizedCourts.first(where: { $0.index == index }) LabeledContent { - if displayContext == .edition { - FooterButtonView("personnaliser") { - if let court { - selectedCourt = court - } else { - let newCourt = Court(index: index, club: tournamentClub.id) - do { - try dataStore.courts.addOrUpdate(instance: newCourt) - } catch { - Logger.error(error) - } - selectedCourt = newCourt + FooterButtonView("personnaliser") { + if let court { + selectedCourt = court + } else { + let newCourt = Court(index: index, club: tournamentClub.id) + do { + try dataStore.courts.addOrUpdate(instance: newCourt) + } catch { + Logger.error(error) } + selectedCourt = newCourt } } + .disabled(displayContext == .lockedForEditing) } label: { if let court { Text(court.courtTitle()) diff --git a/PadelClub/Views/Navigation/MainView.swift b/PadelClub/Views/Navigation/MainView.swift index 1684052..27a27fb 100644 --- a/PadelClub/Views/Navigation/MainView.swift +++ b/PadelClub/Views/Navigation/MainView.swift @@ -9,7 +9,7 @@ import SwiftUI import LeStorage struct MainView: View { - @StateObject var dataStore = DataStore.shared + @EnvironmentObject var dataStore: DataStore @AppStorage("importingFiles") var importingFiles: Bool = false @Environment(NavigationViewModel.self) private var navigation: NavigationViewModel @@ -54,7 +54,7 @@ struct MainView: View { } var badgeText: Text? { - return (Store.main.userName() != nil && _isConnected() == false) ? Text("!").font(.headline) : nil + return (dataStore.appSettings.didCreateAccount && _isConnected() == false) ? Text("!").font(.headline) : nil } var body: some View { diff --git a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift index 731f8a5..8abd950 100644 --- a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift +++ b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift @@ -117,7 +117,7 @@ struct ToolboxView: View { } Section { - Link("Accéder au guide de la compétition", destination: URLs.padelRules.url) + Link("Accéder au guide de la compétition de la FFT", destination: URLs.padelRules.url) } } .navigationTitle(TabDestination.toolbox.title) diff --git a/PadelClub/Views/Navigation/Umpire/UmpireView.swift b/PadelClub/Views/Navigation/Umpire/UmpireView.swift index 04345a2..a39e0b6 100644 --- a/PadelClub/Views/Navigation/Umpire/UmpireView.swift +++ b/PadelClub/Views/Navigation/Umpire/UmpireView.swift @@ -32,6 +32,10 @@ struct UmpireView: View { return URL.importDateFormatter.date(from: lastDataSource) } + enum UmpireScreen { + case login + } + var body: some View { @Bindable var navigation = navigation NavigationStack(path: $navigation.umpirePath) { @@ -56,9 +60,7 @@ struct UmpireView: View { AccountRowView(userName: dataStore.user.username) } } else { - NavigationLink { - LoginView {_ in } - } label: { + NavigationLink(value: UmpireScreen.login) { AccountRowView(userName: dataStore.user.username) } } @@ -210,6 +212,12 @@ struct UmpireView: View { } } } + .navigationDestination(for: UmpireScreen.self) { screen in + switch screen { + case .login: + LoginView {_ in } + } + } } } diff --git a/PadelClub/Views/User/LoginView.swift b/PadelClub/Views/User/LoginView.swift index 695712a..bf9cf8d 100644 --- a/PadelClub/Views/User/LoginView.swift +++ b/PadelClub/Views/User/LoginView.swift @@ -8,6 +8,7 @@ import SwiftUI import LeStorage +typealias Credentials = (username: String, password: String) struct LoginView: View { @EnvironmentObject var dataStore: DataStore @@ -19,11 +20,21 @@ struct LoginView: View { @State var showEmailPopup: Bool = false @State var showUserCreationForm: Bool = false - @State var showEmailValidationMessage: Bool = false @State var showSubscriptionView: Bool = false + @State var credentials: Credentials? @State var errorText: String? = nil + @FocusState var focusedField: UserLogin? + var showEmailValidationMessage: Bool { + credentials != nil + } + + enum UserLogin { + case username + case password + } + var loginFailed: Binding { Binding { errorText != nil @@ -40,16 +51,37 @@ struct LoginView: View { Form { + if self.showEmailValidationMessage || (dataStore.appSettings.didCreateAccount == true && dataStore.appSettings.didRegisterAccount == false) { + Section { + ContentUnavailableView { + Label("Vérifiez vos emails.", systemImage: "envelope.badge") + } description: { + Text("Vous pouvez maintenant ouvrir votre boîte mail pour valider votre compte. Vous pourrez ensuite vous connecter ici. N'oubliez pas de vérifiez vos spams !") + } actions: { + SupportButtonView(contentIsUnavailable: true) + } + } + } + Section { TextField("Nom d'utilisateur", text: self.$username) - .autocorrectionDisabled() + .autocorrectionDisabled(true) + .keyboardType(.asciiCapable) + .textContentType(.init(rawValue: "")) .textInputAutocapitalization(.never) + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit(of: .text) { + focusedField = .password + } SecureField("Mot de passe", text: self.$password) - - } header: { - if self.showEmailValidationMessage { - Text("Vous pouvez maintenant ouvrir votre boîte mail pour valider votre compte. Vous pourrez ensuite vous connecter ici.") - } + .textContentType(.init(rawValue: "")) + .submitLabel(.send) + .onSubmit(of: .text) { + Task { + await self._login() + } + } } Section { @@ -68,8 +100,13 @@ struct LoginView: View { }, label: { Text("Créer un compte") }) - .sheet(isPresented: self.$showUserCreationForm) { - UserCreationFormView(isPresented: self.$showUserCreationForm, showEmailValidationMessage: self.$showEmailValidationMessage) + .sheet(isPresented: self.$showUserCreationForm, onDismiss: { + if let credentials { + self.username = credentials.username + self.password = credentials.password + } + }) { + UserCreationFormView(isPresented: self.$showUserCreationForm, credentials: self.$credentials) } @@ -124,6 +161,10 @@ struct LoginView: View { password: self.password) self.dataStore.user = user self.isLoading = false + await MainActor.run { + dataStore.appSettings.didRegisterAccount = true + dataStore.appSettingsStorage.write() + } self.handler(user) } catch { self.isLoading = false diff --git a/PadelClub/Views/User/UserCreationView.swift b/PadelClub/Views/User/UserCreationView.swift index 827bcf2..659fdbf 100644 --- a/PadelClub/Views/User/UserCreationView.swift +++ b/PadelClub/Views/User/UserCreationView.swift @@ -10,21 +10,12 @@ import LeStorage struct UserCreationFormView: View { - @FocusState private var focusedField: Field? - fileprivate enum Field: Int, Hashable { - case username - case password1 - case password2 - case email - case firstName - case lastName - case phone - } - + @AppStorage("didCreateAccount") var didCreateAccount: Bool = false @EnvironmentObject var networkMonitor: NetworkMonitor + @EnvironmentObject var dataStore: DataStore @Binding var isPresented: Bool - @Binding var showEmailValidationMessage: Bool + @Binding var credentials: Credentials? @State var username: String = "" @State var email: String = "" @@ -51,6 +42,18 @@ struct UserCreationFormView: View { @State var isLoading = false + @FocusState var focusedField: UserCreationFormField? + + enum UserCreationFormField { + case username + case email + case password + case confirmPassword + case firstName + case lastName + case phoneNumber + } + var body: some View { NavigationStack { @@ -58,47 +61,69 @@ struct UserCreationFormView: View { Section { TextField("Nom d'utilisateur", text: self.$username) - .autocorrectionDisabled() + .textContentType(.init(rawValue: "")) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) .textInputAutocapitalization(.never) + .focused($focusedField, equals: .username) .submitLabel(.next) - .focused($focusedField, equals: Field.username) - .onSubmit { self.focusedField = Field.email } + .onSubmit(of: .text) { + focusedField = .email + } TextField("Email", text: self.$email) .keyboardType(.emailAddress) + .textContentType(.emailAddress) .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .email) .submitLabel(.next) - .focused($focusedField, equals: Field.email) - .onSubmit { self.focusedField = Field.password1 } + .onSubmit(of: .text) { + focusedField = .password + } } Section { SecureField("Mot de passe", text: self.$password1) + .focused($focusedField, equals: .password) .submitLabel(.next) - .focused($focusedField, equals: Field.password1) - .onSubmit { self.focusedField = Field.password2 } + .onSubmit(of: .text) { + focusedField = .confirmPassword + } + SecureField("Confirmez le mot de passe", text: self.$password2) + .focused($focusedField, equals: .confirmPassword) .submitLabel(.next) - .focused($focusedField, equals: Field.password2) - .onSubmit { self.focusedField = Field.firstName } + .onSubmit(of: .text) { + focusedField = .firstName + } } Section { TextField("Prénom", text: self.$firstName) + .focused($focusedField, equals: .firstName) + .textContentType(.init(rawValue: "")) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) .submitLabel(.next) - .autocorrectionDisabled() - .focused($focusedField, equals: Field.firstName) - .onSubmit { self.focusedField = Field.lastName } + .onSubmit(of: .text) { + focusedField = .lastName + } TextField("Nom", text: self.$lastName) + .focused($focusedField, equals: .lastName) + .textContentType(.init(rawValue: "")) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) .submitLabel(.next) - .autocorrectionDisabled() - .focused($focusedField, equals: Field.lastName) - .onSubmit { self.focusedField = Field.phone } + .onSubmit(of: .text) { + focusedField = .phoneNumber + } TextField("Téléphone", text: self.$phone) - .submitLabel(.next) - .autocorrectionDisabled() - .focused($focusedField, equals: Field.phone) -// .onSubmit { self.focusNextField($focusedField) } - + .focused($focusedField, equals: .phoneNumber) + .keyboardType(.default) + .textContentType(.telephoneNumber) + .autocorrectionDisabled(true) + .submitLabel(.done) + LabeledContent { Picker(selection: $selectedCountryIndex) { ForEach(0..