diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index 7dcbab7b0..42f2f3225 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -21,8 +21,6 @@ public extension PlatformViewController { } final class ShareExtensionViewModel: ObservableObject { - let extensionContext: NSExtensionContext? - @Published var title: String? @Published var status = ShareExtensionStatus.successfullySaved @Published var debugText: String? @@ -30,11 +28,9 @@ final class ShareExtensionViewModel: ObservableObject { var subscriptions = Set() let requestID = UUID().uuidString.lowercased() - init(extensionContext: NSExtensionContext?) { - self.extensionContext = extensionContext - } + init() {} - func handleReadNowAction() { + func handleReadNowAction(extensionContext: NSExtensionContext?) { #if os(iOS) if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestID)") @@ -44,7 +40,7 @@ final class ShareExtensionViewModel: ObservableObject { extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } - func savePage() { + func savePage(extensionContext: NSExtensionContext?) { PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in switch result { case let .success(payload): @@ -95,21 +91,18 @@ final class ShareExtensionViewModel: ObservableObject { } struct ShareExtensionView: View { - @StateObject private var viewModel: ShareExtensionViewModel - - init(extensionContext: NSExtensionContext?) { - self._viewModel = StateObject(wrappedValue: ShareExtensionViewModel(extensionContext: extensionContext)) - } + let extensionContext: NSExtensionContext? + @StateObject private var viewModel = ShareExtensionViewModel() var body: some View { ShareExtensionChildView( debugText: viewModel.debugText, title: viewModel.title, status: viewModel.status, - onAppearAction: viewModel.savePage, - readNowButtonAction: viewModel.handleReadNowAction, + onAppearAction: { viewModel.savePage(extensionContext: extensionContext) }, + readNowButtonAction: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, dismissButtonTappedAction: { _, _ in - viewModel.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } ) } diff --git a/apple/OmnivoreKit/Sources/App/Views/CreateProfileContainerView.swift b/apple/OmnivoreKit/Sources/App/Views/CreateProfileContainerView.swift deleted file mode 100644 index dc9254c64..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/CreateProfileContainerView.swift +++ /dev/null @@ -1,128 +0,0 @@ -import Combine -import Models -import Services -import SwiftUI -import Utils -import Views - -final class CreateProfileViewModel: ObservableObject { - let initialUserProfile: UserProfile - - var hasSuggestedProfile: Bool { - !(initialUserProfile.name.isEmpty && initialUserProfile.username.isEmpty) - } - - var headlineText: String { - hasSuggestedProfile ? "Confirm Your Profile" : "Create Your Profile" - } - - var submitButtonText: String { - hasSuggestedProfile ? "Confirm" : "Submit" - } - - @Published var loginError: LoginError? - @Published var validationErrorMessage: String? - @Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername - @Published var potentialUsername: String - - var subscriptions = Set() - - init(initialUserProfile: UserProfile, dataService: DataService) { - self.initialUserProfile = initialUserProfile - self.potentialUsername = initialUserProfile.username - - $potentialUsername - .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) - .sink(receiveValue: { [weak self] username in - self?.validateUsername(username: username, dataService: dataService) - }) - .store(in: &subscriptions) - } - - func submitProfile(name: String, bio: String, authenticator: Authenticator) { - let profileOrError = UserProfile.make( - username: potentialUsername, - name: name, - bio: bio.isEmpty ? nil : bio - ) - - switch profileOrError { - case let .left(userProfile): - submitProfile(userProfile: userProfile, authenticator: authenticator) - case let .right(errorMessage): - validationErrorMessage = errorMessage - } - } - - func validateUsername(username: String, dataService: DataService) { - if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) { - potentialUsernameStatus = status - return - } - - dataService.validateUsernamePublisher(username: username).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(usernameError) = completion else { return } - switch usernameError { - case .tooShort: - self?.potentialUsernameStatus = .tooShort - case .tooLong: - self?.potentialUsernameStatus = .tooLong - case .invalidPattern: - self?.potentialUsernameStatus = .invalidPattern - case .nameUnavailable: - self?.potentialUsernameStatus = .unavailable - case .internalServer, .unknown: - self?.loginError = .unknown - case .network: - self?.loginError = .network - } - }, - receiveValue: { [weak self] in - self?.potentialUsernameStatus = .available - } - ) - .store(in: &subscriptions) - } - - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) { - authenticator - .createAccount(userProfile: userProfile).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) - } -} - -struct CreateProfileContainerView: View { - @EnvironmentObject var authenticator: Authenticator - @EnvironmentObject var dataService: DataService - @StateObject private var viewModel: CreateProfileViewModel - - @State private var name: String - @State private var bio = "" - - init(userProfile: UserProfile, dataService: DataService) { - self._viewModel = StateObject( - wrappedValue: CreateProfileViewModel(initialUserProfile: userProfile, dataService: dataService) - ) - self._name = State(initialValue: userProfile.name) - } - - var body: some View { - CreateProfileView( - name: $name, - potentialUsername: $viewModel.potentialUsername, - headlineText: viewModel.headlineText, - submitButtonText: viewModel.submitButtonText, - validationErrorMessage: viewModel.validationErrorMessage, - loginError: viewModel.loginError, - potentialUsernameStatus: viewModel.potentialUsernameStatus, - submitProfileAction: { viewModel.submitProfile(name: $0, bio: $1, authenticator: authenticator) } - ) - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift b/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift index 33feeb86a..6b6f83eae 100644 --- a/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/DebugMenuView.swift @@ -6,14 +6,10 @@ import Views struct DebugMenuView: View { @EnvironmentObject var authenticator: Authenticator @EnvironmentObject var dataService: DataService - @State private var selectedEnvironment: AppEnvironment + @Binding var selectedEnvironment: AppEnvironment let appEnvironments: [AppEnvironment] = [.local, .demo, .dev, .prod] - init(initialEnvironment: AppEnvironment) { - self._selectedEnvironment = State(initialValue: initialEnvironment) - } - var body: some View { VStack { Text("Debug Menu") diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift new file mode 100644 index 000000000..c54160557 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -0,0 +1,240 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +final class CreateProfileViewModel: ObservableObject { + private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil) + var isConfigured = false + + var hasSuggestedProfile: Bool { + !(initialUserProfile.name.isEmpty && initialUserProfile.username.isEmpty) + } + + var headlineText: String { + hasSuggestedProfile ? "Confirm Your Profile" : "Create Your Profile" + } + + var submitButtonText: String { + hasSuggestedProfile ? "Confirm" : "Submit" + } + + @Published var loginError: LoginError? + @Published var validationErrorMessage: String? + @Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername + @Published var potentialUsername = "" + + var subscriptions = Set() + + init() {} + + func submitProfile(name: String, bio: String, authenticator: Authenticator) { + let profileOrError = UserProfile.make( + username: potentialUsername, + name: name, + bio: bio.isEmpty ? nil : bio + ) + + switch profileOrError { + case let .left(userProfile): + submitProfile(userProfile: userProfile, authenticator: authenticator) + case let .right(errorMessage): + validationErrorMessage = errorMessage + } + } + + func validateUsername(username: String, dataService: DataService) { + if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) { + potentialUsernameStatus = status + return + } + + dataService.validateUsernamePublisher(username: username).sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(usernameError) = completion else { return } + switch usernameError { + case .tooShort: + self?.potentialUsernameStatus = .tooShort + case .tooLong: + self?.potentialUsernameStatus = .tooLong + case .invalidPattern: + self?.potentialUsernameStatus = .invalidPattern + case .nameUnavailable: + self?.potentialUsernameStatus = .unavailable + case .internalServer, .unknown: + self?.loginError = .unknown + case .network: + self?.loginError = .network + } + }, + receiveValue: { [weak self] in + self?.potentialUsernameStatus = .available + } + ) + .store(in: &subscriptions) + } + + func submitProfile(userProfile: UserProfile, authenticator: Authenticator) { + authenticator + .createAccount(userProfile: userProfile).sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(loginError) = completion else { return } + self?.loginError = loginError + }, + receiveValue: { _ in } + ) + .store(in: &subscriptions) + } + + func configure(profile: UserProfile, dataService: DataService) { + guard !isConfigured else { return } + + isConfigured = true + initialUserProfile = profile + potentialUsername = profile.username + + $potentialUsername + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .sink(receiveValue: { [weak self] username in + self?.validateUsername(username: username, dataService: dataService) + }) + .store(in: &subscriptions) + } +} + +struct CreateProfileView: View { + private let initialUserProfile: UserProfile + @State private var isConfigured = false + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + @StateObject private var viewModel = CreateProfileViewModel() + + @State private var name = "" + @State private var bio = "" + + init(userProfile: UserProfile) { + self.initialUserProfile = userProfile + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + ScrollView(showsIndicators: false) { + if horizontalSizeClass == .regular { + Spacer(minLength: 150) + } + VStack(alignment: .center, spacing: 16) { + Text(viewModel.headlineText) + .font(.appTitle) + .multilineTextAlignment(.center) + .padding(.bottom, horizontalSizeClass == .compact ? 0 : 50) + .padding(.top, horizontalSizeClass == .compact ? 30 : 0) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.appFootnote) + .foregroundColor(.appGrayText) + #if os(iOS) + TextField("", text: $name) + .textContentType(.name) + .keyboardType(.alphabet) + #elseif os(macOS) + TextField("", text: $name) + #endif + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("Username") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextField("", text: $viewModel.potentialUsername) + } + + if viewModel.potentialUsernameStatus == .available { + Image(systemName: "checkmark.circle.fill") + .font(.appBody) + .foregroundColor(.green) + } + } + if let message = viewModel.potentialUsernameStatus.message { + Text(message) + .font(.appCaption) + .foregroundColor(.red) + } + } + .animation(.default) + + VStack(alignment: .leading, spacing: 6) { + Text("Bio (optional)") + .font(.appFootnote) + .foregroundColor(.appGrayText) + TextEditor(text: $bio) + .lineSpacing(6) + .accentColor(.appGraySolid) + .foregroundColor(.appGrayText) + .font(.appBody) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16) + .strokeBorder(Color.appGrayBorder, lineWidth: 1) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground)) + ) + .frame(height: 160) + } + + Button( + action: { viewModel.submitProfile(name: name, bio: bio, authenticator: authenticator) }, + label: { Text(viewModel.submitButtonText) } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + + if let errorMessage = viewModel.validationErrorMessage { + Text(errorMessage) + .font(.appCaption) + .foregroundColor(.red) + } + + if let loginError = viewModel.loginError, viewModel.validationErrorMessage == nil { + LoginErrorMessageView(loginError: loginError) + } + } + .textFieldStyle(StandardTextFieldStyle()) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() + } + } + .frame(maxWidth: 300) + .onAppear { + guard !isConfigured else { return } + isConfigured = true + name = initialUserProfile.name + viewModel.configure(profile: initialUserProfile, dataService: dataService) + } + } +} + +private extension PotentialUsernameStatus { + var message: String? { + switch self { + case .tooShort: + return "Username must contain at least 4 characters" + case .tooLong: + return "Username must be less than 15 characters" + case .invalidPattern: + return "Username can contain only letters and numbers" + case .unavailable: + return "This name is not available" + case .noUsername, .available: + return nil + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift similarity index 74% rename from apple/OmnivoreKit/Sources/App/Views/NewAppleSignupView.swift rename to apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift index 9529f06f7..d24298116 100644 --- a/apple/OmnivoreKit/Sources/App/Views/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift @@ -6,16 +6,13 @@ import Utils import Views final class NewAppleSignupViewModel: ObservableObject { - let userProfile: UserProfile @Published var loginError: LoginError? var subscriptions = Set() - init(userProfile: UserProfile) { - self.userProfile = userProfile - } + init() {} - func submitProfile(authenticator: Authenticator) { + func submitProfile(userProfile: UserProfile, authenticator: Authenticator) { authenticator .createAccount(userProfile: userProfile).sink( receiveCompletion: { [weak self] completion in @@ -30,14 +27,10 @@ final class NewAppleSignupViewModel: ObservableObject { struct NewAppleSignupView: View { @EnvironmentObject var authenticator: Authenticator - @StateObject private var viewModel: NewAppleSignupViewModel + @StateObject private var viewModel = NewAppleSignupViewModel() + let userProfile: UserProfile let showProfileEditView: () -> Void - init(userProfile: UserProfile, showProfileEditView: @escaping () -> Void) { - self.showProfileEditView = showProfileEditView - self._viewModel = StateObject(wrappedValue: NewAppleSignupViewModel(userProfile: userProfile)) - } - var body: some View { VStack(spacing: 28) { Text("Welcome to Omnivore!") @@ -48,14 +41,14 @@ struct NewAppleSignupView: View { Text("Your username is:") .font(.appBody) .foregroundColor(.appGrayText) - Text("@\(viewModel.userProfile.username)") + Text("@\(userProfile.username)") .font(.appHeadline) .foregroundColor(.appGrayText) } VStack { Button( - action: { viewModel.submitProfile(authenticator: authenticator) }, + action: { viewModel.submitProfile(userProfile: userProfile, authenticator: authenticator) }, label: { Text("Continue") } ) .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) diff --git a/apple/OmnivoreKit/Sources/App/Views/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift similarity index 98% rename from apple/OmnivoreKit/Sources/App/Views/RegistrationView.swift rename to apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index b36090bec..930f196e5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -129,7 +129,7 @@ struct RegistrationView: View { var body: some View { if let registrationState = viewModel.registrationState { if case let RegistrationViewModel.RegistrationState.createProfile(userProfile) = registrationState { - CreateProfileContainerView(userProfile: userProfile, dataService: dataService) + CreateProfileView(userProfile: userProfile) } else if case let RegistrationViewModel.RegistrationState.newAppleSignUp(userProfile) = registrationState { NewAppleSignupView( userProfile: userProfile, diff --git a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift index 757893380..96a5e7521 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift @@ -1,4 +1,5 @@ import Combine +import Models import Services import SwiftUI import Utils @@ -10,6 +11,7 @@ struct WelcomeView: View { @State private var showRegistrationView = false @State private var isKeyboardOnScreen = false @State private var showDebugModal = false + @State private var selectedEnvironment = AppEnvironment.initialAppEnvironment func handleHiddenGestureAction() { if !Bundle.main.isAppStoreBuild { @@ -76,8 +78,9 @@ struct WelcomeView: View { public var body: some View { primaryContent() .sheet(isPresented: $showDebugModal) { - DebugMenuView(initialEnvironment: dataService.appEnvironment) + DebugMenuView(selectedEnvironment: $selectedEnvironment) } .onReceive(Publishers.keyboardHeight) { isKeyboardOnScreen = $0 > 1 } + .onAppear { selectedEnvironment = dataService.appEnvironment } } } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift index d973530bb..a31c56ba3 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift @@ -1,6 +1,7 @@ import AppAuth import Combine import Foundation +import Models import Utils import WebKit @@ -14,6 +15,7 @@ public final class Authenticator: ObservableObject { } @Published public internal(set) var isLoggedIn: Bool + @Published public var pendinguserProfile = UserProfile(username: "", name: "", bio: nil) let networker: Networker diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/CreateProfileView.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/CreateProfileView.swift deleted file mode 100644 index d573e47c9..000000000 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/CreateProfileView.swift +++ /dev/null @@ -1,151 +0,0 @@ -import Models -import SwiftUI -import Utils - -public struct CreateProfileView: View { - @Environment(\.horizontalSizeClass) var horizontalSizeClass - - @State private var bio = "" - - @Binding var name: String - @Binding var potentialUsername: String - let headlineText: String - let submitButtonText: String - let validationErrorMessage: String? - let loginError: LoginError? - let potentialUsernameStatus: PotentialUsernameStatus - let submitProfileAction: (String, String) -> Void - - public init( - name: Binding, - potentialUsername: Binding, - headlineText: String, - submitButtonText: String, - validationErrorMessage: String?, - loginError: LoginError?, - potentialUsernameStatus: PotentialUsernameStatus, - submitProfileAction: @escaping (String, String) -> Void - ) { - self._name = name - self._potentialUsername = potentialUsername - self.headlineText = headlineText - self.submitButtonText = submitButtonText - self.validationErrorMessage = validationErrorMessage - self.loginError = loginError - self.potentialUsernameStatus = potentialUsernameStatus - self.submitProfileAction = submitProfileAction - } - - public var body: some View { - VStack(spacing: 0) { - VStack(spacing: 28) { - ScrollView(showsIndicators: false) { - if horizontalSizeClass == .regular { - Spacer(minLength: 150) - } - VStack(alignment: .center, spacing: 16) { - Text(headlineText) - .font(.appTitle) - .multilineTextAlignment(.center) - .padding(.bottom, horizontalSizeClass == .compact ? 0 : 50) - .padding(.top, horizontalSizeClass == .compact ? 30 : 0) - - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Name") - .font(.appFootnote) - .foregroundColor(.appGrayText) - #if os(iOS) - TextField("", text: $name) - .textContentType(.name) - .keyboardType(.alphabet) - #elseif os(macOS) - TextField("", text: $name) - #endif - } - - VStack(alignment: .leading, spacing: 6) { - HStack { - VStack(alignment: .leading, spacing: 6) { - Text("Username") - .font(.appFootnote) - .foregroundColor(.appGrayText) - TextField("", text: $potentialUsername) - } - - if potentialUsernameStatus == .available { - Image(systemName: "checkmark.circle.fill") - .font(.appBody) - .foregroundColor(.green) - } - } - if let message = potentialUsernameStatus.message { - Text(message) - .font(.appCaption) - .foregroundColor(.red) - } - } - .animation(.default) - - VStack(alignment: .leading, spacing: 6) { - Text("Bio (optional)") - .font(.appFootnote) - .foregroundColor(.appGrayText) - TextEditor(text: $bio) - .lineSpacing(6) - .accentColor(.appGraySolid) - .foregroundColor(.appGrayText) - .font(.appBody) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color.appGrayBorder, lineWidth: 1) - .background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground)) - ) - .frame(height: 160) - } - - Button( - action: { submitProfileAction(name, bio) }, - label: { Text(submitButtonText) } - ) - .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) - - if let errorMessage = validationErrorMessage { - Text(errorMessage) - .font(.appCaption) - .foregroundColor(.red) - } - - if let loginError = loginError, validationErrorMessage == nil { - LoginErrorMessageView(loginError: loginError) - } - } - .textFieldStyle(StandardTextFieldStyle()) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Spacer() - } - } - .frame(maxWidth: 300) - } -} - -private extension PotentialUsernameStatus { - var message: String? { - switch self { - case .tooShort: - return "Username must contain at least 4 characters" - case .tooLong: - return "Username must be less than 15 characters" - case .invalidPattern: - return "Username can contain only letters and numbers" - case .unavailable: - return "This name is not available" - case .noUsername, .available: - return nil - } - } -}