diff --git a/apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift b/apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift new file mode 100644 index 000000000..60ddf6863 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Binders/PrimaryContentCategory.swift @@ -0,0 +1,50 @@ +import SwiftUI +import Views + +enum PrimaryContentCategory: Identifiable, Hashable, Equatable { + case feed + case profile + + static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool { + lhs.id == rhs.id + } + + var id: String { + title + } + + var title: String { + switch self { + case .feed: + return "Home" + case .profile: + return "Profile" + } + } + + var image: Image { + switch self { + case .feed: + return .homeTab + case .profile: + return .profileTab + } + } + + var listLabel: some View { + Label { Text(title) } icon: { image.renderingMode(.template) } + } + + @ViewBuilder var destinationView: some View { + switch self { + case .feed: + HomeFeedView() + case .profile: + ProfileContainerView() + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/apple/OmnivoreKit/Sources/Binders/RootViewModel.swift b/apple/OmnivoreKit/Sources/Binders/RootViewModel.swift index f64616d46..72ceaf526 100644 --- a/apple/OmnivoreKit/Sources/Binders/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/Binders/RootViewModel.swift @@ -145,7 +145,7 @@ public struct RootView: View { @ViewBuilder private var innerBody: some View { if authenticator.isLoggedIn { - PrimaryContentView(services: viewModel.services) + PrimaryContentView() .onAppear { viewModel.triggerPushNotificationRequestIfNeeded() } @@ -170,7 +170,7 @@ public struct RootView: View { #endif } else { - WelcomeView(viewModel: WelcomeViewModel.make(services: viewModel.services)) + WelcomeView() .accessibilityElement() .accessibilityIdentifier("welcomeView") } @@ -185,6 +185,8 @@ public struct RootView: View { .frame(minWidth: 400, idealWidth: 1200, minHeight: 400, idealHeight: 1200) #endif } + .environmentObject(viewModel.services.authenticator) + .environmentObject(viewModel.services.dataService) #if os(iOS) .onOpenURL { url in withoutAnimation { diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/CreateProfileScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/CreateProfileScene.swift deleted file mode 100644 index 9b4f33981..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/CreateProfileScene.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Models -import Services -import SwiftUI -import Utils -import Views - -extension CreateProfileViewModel { - static func make(services: Services, pendingUserProfile: UserProfile) -> CreateProfileViewModel { - let viewModel = CreateProfileViewModel(initialUserProfile: pendingUserProfile) - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case let .submitProfile(userProfile): - self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator) - case let .validateUsername(username: username): - self?.validateUsername(username: username, dataService: services.dataService) - } - } - .store(in: &subscriptions) - } - - private 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) - } - - private 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) - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/DebugMenuScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/DebugMenuScene.swift deleted file mode 100644 index fd0ae8fb5..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/DebugMenuScene.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Services -import SwiftUI -import Views - -extension DebugMenuViewModel { - static func make(services: Services) -> DebugMenuViewModel { - let viewModel = DebugMenuViewModel(initialEnvironment: services.dataService.appEnvironment) - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { action in - switch action { - case let .applyChanges(environment): - switch environment { - case .dev: - services.switchAppEnvironment(to: .dev) - case .demo: - services.switchAppEnvironment(to: .demo) - case .prod: - services.switchAppEnvironment(to: .prod) - case .local: - services.switchAppEnvironment(to: .local) - } - } - } - .store(in: &subscriptions) - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift deleted file mode 100644 index 5f4541645..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/HomeFeedScene.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Models -import Services -import SwiftUI -import UserNotifications -import Utils -import Views - -extension HomeFeedViewModel { - static func make(services: Services) -> HomeFeedViewModel { - let viewModel = HomeFeedViewModel { feedItem in - LinkItemDetailViewModel.make(feedItem: feedItem, services: services) - } - - if UIDevice.isIPhone { - viewModel.profileContainerViewModel = ProfileContainerViewModel.make(services: services) - } - - viewModel.bind(services: services) - viewModel.loadItems(dataService: services.dataService, searchQuery: nil, isRefresh: false) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case let .refreshItems(query: query): - self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: true) - case let .loadItems(query): - self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: false) - case let .archive(linkId): - self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true) - case let .unarchive(linkId): - self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false) - case let .remove(linkId): - self?.removeLink(dataService: services.dataService, linkId: linkId) - case let .snooze(linkId, until, successMessage): - self?.snoozeUntil( - dataService: services.dataService, - linkId: linkId, - until: until, - successMessage: successMessage - ) - } - } - .store(in: &subscriptions) - } - - private func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { - // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set - dataService.clearHighlights() - - let thisSearchIdx = searchIdx - searchIdx += 1 - - isLoading = true - startNetworkActivityIndicator() - - // Cache the viewer - if dataService.currentViewer == nil { - dataService.viewerPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) - } - - dataService.libraryItemsPublisher( - limit: 10, - sortDescending: true, - searchQuery: searchQuery, - cursor: isRefresh ? nil : cursor - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - }, - receiveValue: { [weak self] result in - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { - return - } - self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items - self?.isLoading = false - self?.receivedIdx = thisSearchIdx - self?.cursor = result.cursor - stopNetworkActivityIndicator() - } - ) - .store(in: &subscriptions) - } - - private func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { - isLoading = true - startNetworkActivityIndicator() - - // First remove the link from the internal list, - // then make a call to remove it. The isLoading block should - // prevent our local change from being overwritten, but we - // might need to cache a local list of archived links - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.archiveLinkPublisher(itemID: linkId, archived: archived) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox") - } - ) - .store(in: &subscriptions) - } - - private func removeLink(dataService: DataService, linkId: String) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.removeLinkPublisher(itemID: linkId) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to remove link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: "Link removed") - } - ) - .store(in: &subscriptions) - } - - private func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.createReminderPublisher( - reminderItemId: .link(id: linkId), - remindAt: until - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to snooze") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - if let message = successMessage { - NSNotification.operationSuccess(message: message) - } - } - ) - .store(in: &subscriptions) - } -} - -private func startNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = true - #endif -} - -private func stopNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = false - #endif -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/LinkItemDetailScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/LinkItemDetailScene.swift deleted file mode 100644 index 3955b7e3f..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/LinkItemDetailScene.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Models -import Services -import SwiftUI -import Utils -import Views - -extension LinkItemDetailViewModel { - static func make(feedItem: FeedItem, services: Services) -> LinkItemDetailViewModel { - let viewModel = LinkItemDetailViewModel(item: feedItem) - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case .load: - self?.loadWebAppWrapper(services: services) - case let .updateReadStatus(markAsRead: markAsRead): - self?.updateItemReadStatus(markAsRead: markAsRead, dataService: services.dataService) - } - } - .store(in: &subscriptions) - } - - private func updateItemReadStatus(markAsRead: Bool, dataService: DataService) { - dataService - .updateArticleReadingProgressPublisher( - itemID: item.id, - readingProgress: markAsRead ? 100 : 0, - anchorIndex: 0 - ) - .sink { completion in - guard case let .failure(error) = completion else { return } - print(error) - } receiveValue: { [weak self] feedItem in - self?.item.readingProgress = feedItem.readingProgress - } - .store(in: &subscriptions) - } - - private func loadWebAppWrapper(services: Services) { - // Attempt to get `Viewer` from DataService - if let currentViewer = services.dataService.currentViewer { - createWebAppWrapperViewModel(username: currentViewer.username, services: services) - return - } - - services.dataService.viewerPublisher().sink( - receiveCompletion: { completion in - guard case let .failure(error) = completion else { return } - print(error) - }, - receiveValue: { [weak self] viewer in - self?.createWebAppWrapperViewModel(username: viewer.username, services: services) - } - ) - .store(in: &subscriptions) - } - - private func createWebAppWrapperViewModel(username: String, services: Services) { - let baseURL = services.dataService.appEnvironment.webAppBaseURL - - let urlRequest = URLRequest.webRequest( - baseURL: services.dataService.appEnvironment.webAppBaseURL, - urlPath: "/app/\(username)/\(item.slug)", - queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] - ) - - let newWebAppWrapperViewModel = WebAppWrapperViewModel( - webViewURLRequest: urlRequest, - baseURL: baseURL, - rawAuthCookie: services.authenticator.omnivoreAuthCookieString - ) - - newWebAppWrapperViewModel.performActionSubject.sink { action in - switch action { - case let .shareHighlight(highlightID): - print("show share modal for highlight with id: \(highlightID)") - } - } - .store(in: &newWebAppWrapperViewModel.subscriptions) - - webAppWrapperViewModel = newWebAppWrapperViewModel - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/NewAppleSignupScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/NewAppleSignupScene.swift deleted file mode 100644 index 3d26d4198..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/NewAppleSignupScene.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Models -import Services -import SwiftUI -import Utils -import Views - -extension NewAppleSignupViewModel { - static func make( - services: Services, - userProfile: UserProfile, - showProfileEditView: @escaping () -> Void - ) -> NewAppleSignupViewModel { - let viewModel = NewAppleSignupViewModel(userProfile: userProfile) - viewModel.bind(services: services, showProfileEditView: showProfileEditView) - return viewModel - } - - func bind(services: Services, showProfileEditView: @escaping () -> Void) { - performActionSubject.sink { [weak self] action in - switch action { - case let .acceptProfile(userProfile: userProfile): - self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator) - case .changeProfile: - showProfileEditView() - } - } - .store(in: &subscriptions) - } - - private 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) - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/ProfileContainerScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/ProfileContainerScene.swift deleted file mode 100644 index 86a7dba1d..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/ProfileContainerScene.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Services -import SwiftUI -import Views - -extension ProfileContainerViewModel { - static func make(services: Services) -> ProfileContainerViewModel { - let viewModel = ProfileContainerViewModel() - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case .logout: - services.authenticator.logout() - case .loadProfileData: - self?.loadProfileData(dataService: services.dataService) - case .showIntercomMessenger: - DataService.showIntercomMessenger?() - case .deleteAccount: - print("delete account") - } - } - .store(in: &subscriptions) - } - - private func loadProfileData(dataService: DataService) { - dataService.viewerPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] viewer in - self?.profileCardData = ProfileCardData( - name: viewer.name, - username: viewer.username, - imageURL: viewer.profileImageURL.flatMap { URL(string: $0) } - ) - } - ) - .store(in: &subscriptions) - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/RegistrationScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/RegistrationScene.swift deleted file mode 100644 index 3ff2640c0..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/RegistrationScene.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AuthenticationServices -import Models -import Services -import SwiftUI -import Utils -import Views - -extension RegistrationViewModel { - static func make(services: Services) -> RegistrationViewModel { - let viewModel = RegistrationViewModel() - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - self?.loginError = nil - - switch action { - case .googleButtonTapped: - self?.handleGoogleAuth(services: services) - case let .appleSignInCompleted(result: result): - switch AppleSigninPayload.parse(authResult: result) { - case let .success(payload): - self?.handleAppleToken(payload: payload, services: services) - case let .failure(error): - switch error { - case .unauthorized, .unknown: - break - case .network: - self?.loginError = error - } - } - } - } - .store(in: &subscriptions) - } - - private func handleAppleToken(payload: AppleSigninPayload, services: Services) { - services.authenticator.submitAppleToken(token: payload.token).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - switch loginError { - case .unauthorized, .unknown: - self?.handleAppleSignUp(services: services, payload: payload) - case .network: - self?.loginError = loginError - } - }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) - } - - private func handleAppleSignUp(services: Services, payload: AppleSigninPayload) { - services.authenticator - .createPendingAccountUsingApple(token: payload.token, name: payload.fullName) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { [weak self] userProfile in - if userProfile.name.isEmpty { - self?.showProfileEditView(services: services, pendingUserProfile: userProfile) - } else { - self?.newAppleSignupViewModel = NewAppleSignupViewModel.make( - services: services, - userProfile: userProfile, - showProfileEditView: { - self?.showProfileEditView(services: services, pendingUserProfile: userProfile) - } - ) - } - } - ) - .store(in: &subscriptions) - } - - private func handleGoogleAuth(services: Services) { - services.authenticator - .handleGoogleAuth(presentingViewController: presentingViewController()) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { [weak self] isNewAccount in - if isNewAccount { - let pendingUserProfile = UserProfile(username: "", name: "") - self?.showProfileEditView(services: services, pendingUserProfile: pendingUserProfile) - } - } - ) - .store(in: &subscriptions) - } - - func showProfileEditView(services: Services, pendingUserProfile: UserProfile) { - createProfileViewModel = CreateProfileViewModel.make( - services: services, - pendingUserProfile: pendingUserProfile - ) - newAppleSignupViewModel = nil - } -} - -private func presentingViewController() -> PlatformViewController? { - #if os(iOS) - return UIApplication.shared.windows - .filter(\.isKeyWindow) - .first? - .rootViewController - #elseif os(macOS) - return nil - #endif -} diff --git a/apple/OmnivoreKit/Sources/Binders/Scenes/WelcomeScene.swift b/apple/OmnivoreKit/Sources/Binders/Scenes/WelcomeScene.swift deleted file mode 100644 index f9fcf48a3..000000000 --- a/apple/OmnivoreKit/Sources/Binders/Scenes/WelcomeScene.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Services -import SwiftUI -import Utils -import Views - -extension WelcomeViewModel { - static func make(services: Services) -> WelcomeViewModel { - let registrationViewModel = RegistrationViewModel.make(services: services) - let viewModel = WelcomeViewModel(registrationViewModel: registrationViewModel) - viewModel.bind(services: services) - return viewModel - } - - func bind(services: Services) { - performActionSubject.sink { [weak self] action in - switch action { - case .hiddenGesturePerformed: - if !Bundle.main.isAppStoreBuild { - self?.debugMenuViewModel = DebugMenuViewModel.make(services: services) - self?.showDebugModal = true - } - } - } - .store(in: &subscriptions) - } -} diff --git a/apple/OmnivoreKit/Sources/Binders/Services.swift b/apple/OmnivoreKit/Sources/Binders/Services.swift index 041dc7298..c73ff44bb 100644 --- a/apple/OmnivoreKit/Sources/Binders/Services.swift +++ b/apple/OmnivoreKit/Sources/Binders/Services.swift @@ -11,9 +11,4 @@ public final class Services { self.authenticator = Authenticator(networker: networker) self.dataService = DataService(appEnvironment: appEnvironment, networker: networker) } - - public func switchAppEnvironment(to appEnvironment: AppEnvironment) { - authenticator.logout() - dataService.switchAppEnvironment(appEnvironment: appEnvironment) - } } diff --git a/apple/OmnivoreKit/Sources/Views/ProfileViews/CreateProfileView.swift b/apple/OmnivoreKit/Sources/Binders/Views/CreateProfileView.swift similarity index 67% rename from apple/OmnivoreKit/Sources/Views/ProfileViews/CreateProfileView.swift rename to apple/OmnivoreKit/Sources/Binders/Views/CreateProfileView.swift index 8fb6e0e32..06f608c10 100644 --- a/apple/OmnivoreKit/Sources/Views/ProfileViews/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/CreateProfileView.swift @@ -1,8 +1,11 @@ import Combine import Models +import Services import SwiftUI +import Utils +import Views -public final class CreateProfileViewModel: ObservableObject { +final class CreateProfileViewModel: ObservableObject { let initialUserProfile: UserProfile var hasSuggestedProfile: Bool { @@ -17,32 +20,26 @@ public final class CreateProfileViewModel: ObservableObject { hasSuggestedProfile ? "Confirm" : "Submit" } - @Published public var loginError: LoginError? + @Published var loginError: LoginError? @Published var validationErrorMessage: String? - @Published public var potentialUsernameStatus = PotentialUsernameStatus.noUsername + @Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername @Published var potentialUsername: String - public enum Action { - case submitProfile(userProfile: UserProfile) - case validateUsername(username: String) - } + var subscriptions = Set() - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init(initialUserProfile: UserProfile) { + 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?.performActionSubject.send(.validateUsername(username: username)) + self?.validateUsername(username: username, dataService: dataService) }) .store(in: &subscriptions) } - func submitProfile(name: String, bio: String) { + func submitProfile(name: String, bio: String, authenticator: Authenticator) { let profileOrError = UserProfile.make( username: potentialUsername, name: name, @@ -51,30 +48,75 @@ public final class CreateProfileViewModel: ObservableObject { switch profileOrError { case let .left(userProfile): - performActionSubject.send(.submitProfile(userProfile: 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) + } } -public struct CreateProfileView: View { +struct CreateProfileView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject private var viewModel: CreateProfileViewModel @State private var name: String @State private var bio = "" - public init(viewModel: CreateProfileViewModel) { - self.viewModel = viewModel - self._name = State(initialValue: viewModel.initialUserProfile.name) + init(userProfile: UserProfile, dataService: DataService) { + self.viewModel = CreateProfileViewModel(initialUserProfile: userProfile, dataService: dataService) + self._name = State(initialValue: userProfile.name) } private func didTapSubmitButton() { - viewModel.submitProfile(name: name, bio: bio) + viewModel.submitProfile(name: name, bio: bio, authenticator: authenticator) } - public var body: some View { + var body: some View { VStack(spacing: 0) { VStack(spacing: 28) { ScrollView(showsIndicators: false) { diff --git a/apple/OmnivoreKit/Sources/Binders/Views/DebugMenuView.swift b/apple/OmnivoreKit/Sources/Binders/Views/DebugMenuView.swift new file mode 100644 index 000000000..33feeb86a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Binders/Views/DebugMenuView.swift @@ -0,0 +1,42 @@ +import Models +import Services +import SwiftUI +import Views + +struct DebugMenuView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + @State private 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") + .font(.appTitle) + Form { + Text("API Environment:") + Picker(selection: $selectedEnvironment, label: Text("API Environment:")) { + ForEach(appEnvironments, id: \.self) { + Text($0.rawValue) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + + Button( + action: { + authenticator.logout() + dataService.switchAppEnvironment(appEnvironment: selectedEnvironment) + }, + label: { Text("Apply Changes") } + ) + .buttonStyle(SolidCapsuleButtonStyle(width: 220)) + } + .padding() + } +} diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift b/apple/OmnivoreKit/Sources/Binders/Views/HomeFeedView.swift similarity index 52% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift rename to apple/OmnivoreKit/Sources/Binders/Views/HomeFeedView.swift index cea24590e..f87f7eadc 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/HomeFeedView.swift @@ -1,57 +1,186 @@ import Combine import Models +import Services import SwiftUI +import UserNotifications import Utils +import Views -public final class HomeFeedViewModel: ObservableObject { - let detailViewModelCreator: (FeedItem) -> LinkItemDetailViewModel +final class HomeFeedViewModel: ObservableObject { var currentDetailViewModel: LinkItemDetailViewModel? - public var profileContainerViewModel: ProfileContainerViewModel? - @Published public var items = [FeedItem]() - @Published public var isLoading = false - @Published public var showPushNotificationPrimer = false - public var cursor: String? + @Published var items = [FeedItem]() + @Published var isLoading = false + @Published var showPushNotificationPrimer = false + var cursor: String? // These are used to make sure we handle search result // responses in the right order - public var searchIdx = 0 - public var receivedIdx = 0 + var searchIdx = 0 + var receivedIdx = 0 - public enum Action { - case refreshItems(query: String) - case loadItems(query: String) - case archive(linkId: String) - case unarchive(linkId: String) - case remove(linkId: String) - case snooze(linkId: String, until: Date, successMessage: String?) - } + var subscriptions = Set() - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() + init() {} - public init(detailViewModelCreator: @escaping (FeedItem) -> LinkItemDetailViewModel) { - self.detailViewModelCreator = detailViewModelCreator - } - - func itemAppeared(item: FeedItem, searchQuery: String) { + func itemAppeared(item: FeedItem, searchQuery: String, dataService: DataService) { if isLoading { return } let itemIndex = items.firstIndex(where: { $0.id == item.id }) let thresholdIndex = items.index(items.endIndex, offsetBy: -5) // Check if user has scrolled to the last five items in the list if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 { - performActionSubject.send(.loadItems(query: searchQuery)) + loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: false) } } func pushFeedItem(item: FeedItem) { items.insert(item, at: 0) } + + func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { + // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set + dataService.clearHighlights() + + let thisSearchIdx = searchIdx + searchIdx += 1 + + isLoading = true + startNetworkActivityIndicator() + + // Cache the viewer + if dataService.currentViewer == nil { + dataService.viewerPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { _ in } + ) + .store(in: &subscriptions) + } + + dataService.libraryItemsPublisher( + limit: 10, + sortDescending: true, + searchQuery: searchQuery, + cursor: isRefresh ? nil : cursor + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + }, + receiveValue: { [weak self] result in + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { + return + } + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items + self?.isLoading = false + self?.receivedIdx = thisSearchIdx + self?.cursor = result.cursor + stopNetworkActivityIndicator() + } + ) + .store(in: &subscriptions) + } + + func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { + isLoading = true + startNetworkActivityIndicator() + + // First remove the link from the internal list, + // then make a call to remove it. The isLoading block should + // prevent our local change from being overwritten, but we + // might need to cache a local list of archived links + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.archiveLinkPublisher(itemID: linkId, archived: archived) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox") + } + ) + .store(in: &subscriptions) + } + + func removeLink(dataService: DataService, linkId: String) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.removeLinkPublisher(itemID: linkId) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: "Failed to remove link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + NSNotification.operationSuccess(message: "Link removed") + } + ) + .store(in: &subscriptions) + } + + func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.createReminderPublisher( + reminderItemId: .link(id: linkId), + remindAt: until + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: "Failed to snooze") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + if let message = successMessage { + NSNotification.operationSuccess(message: message) + } + } + ) + .store(in: &subscriptions) + } } -public struct HomeFeedView: View { - @ObservedObject private var viewModel: HomeFeedViewModel +struct HomeFeedView: View { + @EnvironmentObject var dataService: DataService + + @ObservedObject private var viewModel = HomeFeedViewModel() @State private var selectedLinkItem: FeedItem? @State private var searchQuery = "" @State private var itemToRemove: FeedItem? @@ -59,10 +188,6 @@ public struct HomeFeedView: View { @State private var snoozePresented = false @State private var itemToSnooze: FeedItem? - public init(viewModel: HomeFeedViewModel) { - self.viewModel = viewModel - } - @ViewBuilder var conditionalInnerBody: some View { #if os(iOS) if #available(iOS 15.0, *) { @@ -117,7 +242,7 @@ public struct HomeFeedView: View { ForEach(viewModel.items) { item in let link = ZStack { NavigationLink( - destination: LinkItemDetailView(viewModel: viewModel.detailViewModelCreator(item)), + destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), tag: item, selection: $selectedLinkItem ) { @@ -126,14 +251,14 @@ public struct HomeFeedView: View { .opacity(0) .buttonStyle(PlainButtonStyle()) .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery) + viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) } FeedCard(item: item) }.contextMenu { if !item.isArchived { Button(action: { withAnimation(.linear(duration: 0.4)) { - viewModel.performActionSubject.send(.archive(linkId: item.id)) + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) if item == selectedLinkItem { selectedLinkItem = nil } @@ -142,7 +267,7 @@ public struct HomeFeedView: View { } else { Button(action: { withAnimation(.linear(duration: 0.4)) { - viewModel.performActionSubject.send(.unarchive(linkId: item.id)) + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) } }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) } @@ -160,7 +285,7 @@ public struct HomeFeedView: View { if !item.isArchived { Button { withAnimation(.linear(duration: 0.4)) { - viewModel.performActionSubject.send(.archive(linkId: item.id)) + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) } } label: { Label("Archive", systemImage: "archivebox") @@ -168,7 +293,7 @@ public struct HomeFeedView: View { } else { Button { withAnimation(.linear(duration: 0.4)) { - viewModel.performActionSubject.send(.unarchive(linkId: item.id)) + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) } } label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") @@ -190,7 +315,7 @@ public struct HomeFeedView: View { Button("Remove Link", role: .destructive) { if let itemToRemove = itemToRemove { withAnimation { - viewModel.performActionSubject.send(.remove(linkId: itemToRemove.id)) + viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) } } self.itemToRemove = nil @@ -242,8 +367,11 @@ public struct HomeFeedView: View { } .formSheet(isPresented: $snoozePresented) { SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) { - viewModel.performActionSubject.send( - .snooze(linkId: $0.feedItemId, until: $0.snoozeUntilDate, successMessage: $0.successMessage) + viewModel.snoozeUntil( + dataService: dataService, + linkId: $0.feedItemId, + until: $0.snoozeUntilDate, + successMessage: $0.successMessage ) } } @@ -255,17 +383,15 @@ public struct HomeFeedView: View { } } - public var body: some View { + var body: some View { #if os(iOS) - if UIDevice.isIPhone, let profileContainerViewModel = viewModel.profileContainerViewModel { + if UIDevice.isIPhone { NavigationView { conditionalInnerBody .toolbar { ToolbarItem { NavigationLink( - destination: { - ProfileContainerView(viewModel: profileContainerViewModel) - }, + destination: { ProfileContainerView() }, label: { Image.profile .resizable() @@ -286,6 +412,18 @@ public struct HomeFeedView: View { } private func refresh() { - viewModel.performActionSubject.send(.refreshItems(query: searchQuery)) + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) } } + +private func startNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = true + #endif +} + +private func stopNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = false + #endif +} diff --git a/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/Binders/Views/LinkItemDetailView.swift similarity index 60% rename from apple/OmnivoreKit/Sources/Views/LinkedItemDetail/LinkItemDetailView.swift rename to apple/OmnivoreKit/Sources/Binders/Views/LinkItemDetailView.swift index 3250a37e8..7753cd4b6 100644 --- a/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/LinkItemDetailView.swift @@ -1,30 +1,102 @@ import Combine import Models +import Services import SwiftUI import Utils +import Views -public enum PDFProvider { - public static var pdfViewerProvider: ((URL, FeedItem) -> AnyView)? +enum PDFProvider { + static var pdfViewerProvider: ((URL, FeedItem) -> AnyView)? } -public final class LinkItemDetailViewModel: ObservableObject { - @Published public var item: FeedItem - @Published public var webAppWrapperViewModel: WebAppWrapperViewModel? +final class LinkItemDetailViewModel: ObservableObject { + @Published var item: FeedItem + @Published var webAppWrapperViewModel: WebAppWrapperViewModel? - public enum Action { + enum Action { case load case updateReadStatus(markAsRead: Bool) } - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() + var subscriptions = Set() - public init(item: FeedItem) { + init(item: FeedItem) { self.item = item } + + func updateItemReadStatus(dataService: DataService) { + dataService + .updateArticleReadingProgressPublisher( + itemID: item.id, + readingProgress: item.isRead ? 0 : 100, + anchorIndex: 0 + ) + .sink { completion in + guard case let .failure(error) = completion else { return } + print(error) + } receiveValue: { [weak self] feedItem in + self?.item.readingProgress = feedItem.readingProgress + } + .store(in: &subscriptions) + } + + func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) { + // Attempt to get `Viewer` from DataService + if let currentViewer = dataService.currentViewer { + createWebAppWrapperViewModel( + username: currentViewer.username, + dataService: dataService, + rawAuthCookie: rawAuthCookie + ) + return + } + + dataService.viewerPublisher().sink( + receiveCompletion: { completion in + guard case let .failure(error) = completion else { return } + print(error) + }, + receiveValue: { [weak self] viewer in + self?.createWebAppWrapperViewModel( + username: viewer.username, + dataService: dataService, + rawAuthCookie: rawAuthCookie + ) + } + ) + .store(in: &subscriptions) + } + + private func createWebAppWrapperViewModel(username: String, dataService: DataService, rawAuthCookie: String?) { + let baseURL = dataService.appEnvironment.webAppBaseURL + + let urlRequest = URLRequest.webRequest( + baseURL: dataService.appEnvironment.webAppBaseURL, + urlPath: "/app/\(username)/\(item.slug)", + queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] + ) + + let newWebAppWrapperViewModel = WebAppWrapperViewModel( + webViewURLRequest: urlRequest, + baseURL: baseURL, + rawAuthCookie: rawAuthCookie + ) + + newWebAppWrapperViewModel.performActionSubject.sink { action in + switch action { + case let .shareHighlight(highlightID): + print("show share modal for highlight with id: \(highlightID)") + } + } + .store(in: &newWebAppWrapperViewModel.subscriptions) + + webAppWrapperViewModel = newWebAppWrapperViewModel + } } -public struct LinkItemDetailView: View { +struct LinkItemDetailView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService @Environment(\.presentationMode) var presentationMode: Binding static let navBarHeight = 50.0 @@ -32,13 +104,15 @@ public struct LinkItemDetailView: View { @State private var showFontSizePopover = false @State private var navBarVisibilityRatio = 1.0 - public init(viewModel: LinkItemDetailViewModel) { + init(viewModel: LinkItemDetailViewModel) { self.viewModel = viewModel } var toggleReadStatusToolbarItem: some View { Button( - action: { viewModel.performActionSubject.send(.updateReadStatus(markAsRead: !viewModel.item.isRead)) }, + action: { + viewModel.updateItemReadStatus(dataService: dataService) + }, label: { Image(systemName: viewModel.item.isRead ? "line.horizontal.3.decrease.circle" : "checkmark.circle") } @@ -61,7 +135,7 @@ public struct LinkItemDetailView: View { ) } - public var body: some View { + var body: some View { #if os(iOS) if UIDevice.isIPhone, !viewModel.item.isPDF { compactInnerBody @@ -96,7 +170,7 @@ public struct LinkItemDetailView: View { .padding(.horizontal) .scaleEffect(navBarVisibilityRatio) } - .frame(height: LinkItemDetailView.navBarHeight * navBarVisibilityRatio) + .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) .opacity(navBarVisibilityRatio) .background(Color.systemBackground) } @@ -148,7 +222,10 @@ public struct LinkItemDetailView: View { Spacer() } .onAppear { - viewModel.performActionSubject.send(.load) + viewModel.loadWebAppWrapper( + dataService: dataService, + rawAuthCookie: authenticator.omnivoreAuthCookieString + ) } .navigationBarHidden(true) } @@ -191,7 +268,10 @@ public struct LinkItemDetailView: View { Spacer() } .onAppear { - viewModel.performActionSubject.send(.load) + viewModel.loadWebAppWrapper( + dataService: dataService, + rawAuthCookie: authenticator.omnivoreAuthCookieString + ) } } } diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/Binders/Views/NewAppleSignupView.swift similarity index 50% rename from apple/OmnivoreKit/Sources/Views/RegistrationViews/NewAppleSignupView.swift rename to apple/OmnivoreKit/Sources/Binders/Views/NewAppleSignupView.swift index b05eb7030..d433ede10 100644 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/NewAppleSignupView.swift @@ -1,32 +1,44 @@ import Combine import Models +import Services import SwiftUI +import Utils +import Views -public final class NewAppleSignupViewModel: ObservableObject { +final class NewAppleSignupViewModel: ObservableObject { let userProfile: UserProfile - @Published public var loginError: LoginError? + @Published var loginError: LoginError? - public enum Action { - case acceptProfile(userProfile: UserProfile) - case changeProfile + var subscriptions = Set() + + init(userProfile: UserProfile) { + self.userProfile = userProfile } - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init(userProfile: UserProfile) { - self.userProfile = userProfile + func submitProfile(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) } } -public struct NewAppleSignupView: View { +struct NewAppleSignupView: View { + @EnvironmentObject var authenticator: Authenticator @ObservedObject private var viewModel: NewAppleSignupViewModel + let showProfileEditView: () -> Void - public init(viewModel: NewAppleSignupViewModel) { - self.viewModel = viewModel + init(userProfile: UserProfile, showProfileEditView: @escaping () -> Void) { + self.showProfileEditView = showProfileEditView + self.viewModel = NewAppleSignupViewModel(userProfile: userProfile) } - public var body: some View { + var body: some View { VStack(spacing: 28) { Text("Welcome to Omnivore!") .font(.appTitle) @@ -43,13 +55,13 @@ public struct NewAppleSignupView: View { VStack { Button( - action: { viewModel.performActionSubject.send(.acceptProfile(userProfile: viewModel.userProfile)) }, + action: { viewModel.submitProfile(authenticator: authenticator) }, label: { Text("Continue") } ) .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) Button( - action: { viewModel.performActionSubject.send(.changeProfile) }, + action: showProfileEditView, label: { Text("Change Username") } ) .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) diff --git a/apple/OmnivoreKit/Sources/Binders/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/Binders/Views/PrimaryContentView.swift index 3c6fa23be..422e770cb 100644 --- a/apple/OmnivoreKit/Sources/Binders/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/PrimaryContentView.swift @@ -4,36 +4,23 @@ import SwiftUI import Views public struct PrimaryContentView: View { - let homeFeedViewModel: HomeFeedViewModel - let profileContainerViewModel: ProfileContainerViewModel - - public init(services: Services) { - self.homeFeedViewModel = HomeFeedViewModel.make(services: services) - self.profileContainerViewModel = ProfileContainerViewModel.make(services: services) - } - public var body: some View { #if os(iOS) if UIDevice.isIPad { regularView } else { - compactView + HomeFeedView() } #elseif os(macOS) regularView #endif } - // iphone view container - private var compactView: some View { - HomeFeedView(viewModel: homeFeedViewModel) - } - // ipad and mac view container private var regularView: some View { let categories = [ - PrimaryContentCategory.feed(viewModel: homeFeedViewModel), - PrimaryContentCategory.profile(viewModel: profileContainerViewModel) + PrimaryContentCategory.feed, + PrimaryContentCategory.profile ] return NavigationView { diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileContainerView.swift b/apple/OmnivoreKit/Sources/Binders/Views/ProfileContainerView.swift similarity index 65% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileContainerView.swift rename to apple/OmnivoreKit/Sources/Binders/Views/ProfileContainerView.swift index 3c3a9fc16..111483915 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileContainerView.swift +++ b/apple/OmnivoreKit/Sources/Binders/Views/ProfileContainerView.swift @@ -1,34 +1,39 @@ import Combine import Models +import Services import SwiftUI import Utils +import Views -public final class ProfileContainerViewModel: ObservableObject { - @Published public var isLoading = false - @Published public var profileCardData = ProfileCardData() +final class ProfileContainerViewModel: ObservableObject { + @Published var isLoading = false + @Published var profileCardData = ProfileCardData() - public enum Action { - case logout - case loadProfileData - case showIntercomMessenger - case deleteAccount + var subscriptions = Set() + + func loadProfileData(dataService: DataService) { + dataService.viewerPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] viewer in + self?.profileCardData = ProfileCardData( + name: viewer.name, + username: viewer.username, + imageURL: viewer.profileImageURL.flatMap { URL(string: $0) } + ) + } + ) + .store(in: &subscriptions) } - - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init() {} } -public struct ProfileContainerView: View { - @ObservedObject private var viewModel: ProfileContainerViewModel +struct ProfileContainerView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + + @ObservedObject private var viewModel = ProfileContainerViewModel() @State private var showLogoutConfirmation = false - public init(viewModel: ProfileContainerViewModel) { - self.viewModel = viewModel - } - - public var body: some View { + var body: some View { #if os(iOS) Form { innerBody @@ -46,7 +51,7 @@ public struct ProfileContainerView: View { Section { ProfileCard(data: viewModel.profileCardData) .onAppear { - viewModel.performActionSubject.send(.loadProfileData) + viewModel.loadProfileData(dataService: dataService) } } @@ -62,7 +67,7 @@ public struct ProfileContainerView: View { #if os(iOS) Button( action: { - viewModel.performActionSubject.send(.showIntercomMessenger) + DataService.showIntercomMessenger?() }, label: { Text("Feedback") } ) @@ -73,7 +78,7 @@ public struct ProfileContainerView: View { if FeatureFlag.showAccountDeletion { NavigationLink( destination: ManageAccountView(handleAccountDeletion: { - viewModel.performActionSubject.send(.deleteAccount) + print("delete account") }) ) { Text("Manage Account") @@ -88,7 +93,7 @@ public struct ProfileContainerView: View { Alert( title: Text("Are you sure you want to logout?"), primaryButton: .destructive(Text("Confirm")) { - viewModel.performActionSubject.send(.logout) + authenticator.logout() }, secondaryButton: .cancel() ) diff --git a/apple/OmnivoreKit/Sources/Binders/Views/RegistrationView.swift b/apple/OmnivoreKit/Sources/Binders/Views/RegistrationView.swift new file mode 100644 index 000000000..aabb45de8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Binders/Views/RegistrationView.swift @@ -0,0 +1,156 @@ +import AuthenticationServices +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +final class RegistrationViewModel: ObservableObject { + enum RegistrationState { + case createProfile(userProfile: UserProfile) + case newAppleSignUp(userProfile: UserProfile) + } + + @Published var loginError: LoginError? + @Published var registrationState: RegistrationState? + + var subscriptions = Set() + + func handleAppleSignInCompletion(result: Result, authenticator: Authenticator) { + switch AppleSigninPayload.parse(authResult: result) { + case let .success(payload): + handleAppleToken(payload: payload, authenticator: authenticator) + case let .failure(error): + switch error { + case .unauthorized, .unknown: + break + case .network: + loginError = error + } + } + } + + private func handleAppleToken(payload: AppleSigninPayload, authenticator: Authenticator) { + authenticator.submitAppleToken(token: payload.token).sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(loginError) = completion else { return } + switch loginError { + case .unauthorized, .unknown: + self?.handleAppleSignUp(authenticator: authenticator, payload: payload) + case .network: + self?.loginError = loginError + } + }, + receiveValue: { _ in } + ) + .store(in: &subscriptions) + } + + private func handleAppleSignUp(authenticator: Authenticator, payload: AppleSigninPayload) { + authenticator + .createPendingAccountUsingApple(token: payload.token, name: payload.fullName) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(loginError) = completion else { return } + self?.loginError = loginError + }, + receiveValue: { [weak self] userProfile in + if userProfile.name.isEmpty { + self?.registrationState = .createProfile(userProfile: userProfile) + } else { + self?.registrationState = .newAppleSignUp(userProfile: userProfile) + } + } + ) + .store(in: &subscriptions) + } + + func handleGoogleAuth(authenticator: Authenticator) { + authenticator + .handleGoogleAuth(presentingViewController: presentingViewController()) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(loginError) = completion else { return } + self?.loginError = loginError + }, + receiveValue: { [weak self] isNewAccount in + if isNewAccount { + self?.registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) + } + } + ) + .store(in: &subscriptions) + } +} + +struct RegistrationView: View { + @EnvironmentObject var authenticator: Authenticator + @EnvironmentObject var dataService: DataService + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @ObservedObject private var viewModel = RegistrationViewModel() + + var authenticationView: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + if horizontalSizeClass == .regular { + Spacer() + } + + VStack(alignment: .center, spacing: 16) { + Text(LocalText.registrationViewHeadline) + .font(.appTitle) + .multilineTextAlignment(.center) + .padding(.bottom, horizontalSizeClass == .compact ? 0 : 50) + .padding(.top, horizontalSizeClass == .compact ? 30 : 0) + + AppleSignInButton { + viewModel.handleAppleSignInCompletion(result: $0, authenticator: authenticator) + } + + if AppKeys.sharedInstance?.iosClientGoogleId != nil { + GoogleAuthButton { + viewModel.handleGoogleAuth(authenticator: authenticator) + } + } + } + + if let loginError = viewModel.loginError { + LoginErrorMessageView(loginError: loginError) + } + + Spacer() + } + .frame(maxWidth: 316) + .padding(.horizontal, 16) + } + } + + var body: some View { + if let registrationState = viewModel.registrationState { + if case let RegistrationViewModel.RegistrationState.createProfile(userProfile) = registrationState { + CreateProfileView(userProfile: userProfile, dataService: dataService) + } else if case let RegistrationViewModel.RegistrationState.newAppleSignUp(userProfile) = registrationState { + NewAppleSignupView( + userProfile: userProfile, + showProfileEditView: { viewModel.registrationState = .createProfile(userProfile: userProfile) } + ) + } else { + authenticationView + } + } else { + authenticationView + } + } +} + +private func presentingViewController() -> PlatformViewController? { + #if os(iOS) + return UIApplication.shared.windows + .filter(\.isKeyWindow) + .first? + .rootViewController + #elseif os(macOS) + return nil + #endif +} diff --git a/apple/OmnivoreKit/Sources/Binders/Views/WelcomeView.swift b/apple/OmnivoreKit/Sources/Binders/Views/WelcomeView.swift new file mode 100644 index 000000000..757893380 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Binders/Views/WelcomeView.swift @@ -0,0 +1,83 @@ +import Combine +import Services +import SwiftUI +import Utils +import Views + +struct WelcomeView: View { + @EnvironmentObject var dataService: DataService + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @State private var showRegistrationView = false + @State private var isKeyboardOnScreen = false + @State private var showDebugModal = false + + func handleHiddenGestureAction() { + if !Bundle.main.isAppStoreBuild { + showDebugModal = true + } + } + + @ViewBuilder func userInteractiveView(width: CGFloat) -> some View { + Group { + if showRegistrationView { + RegistrationView() + } else { + GetStartedView(showRegistrationView: $showRegistrationView) + } + } + .frame(width: width) + .zIndex(2) + } + + @ViewBuilder func primaryContent() -> some View { + if horizontalSizeClass == .compact { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Color.systemBackground + .edgesIgnoringSafeArea(.all) + + if geometry.size.width < geometry.size.height, !isKeyboardOnScreen { + VStack { + Color.appDeepBackground.frame(height: 100) + Spacer() + } + .edgesIgnoringSafeArea(.all) + } + + VStack { + if geometry.size.width < geometry.size.height, !isKeyboardOnScreen { + RegistrationHeroImageView(tapGestureHandler: handleHiddenGestureAction) + } + userInteractiveView(width: geometry.size.width) + Spacer() + } + } + } + } else { + GeometryReader { geometry in + ZStack(alignment: .leading) { + SplitColorBackground(width: geometry.size.width) + + VStack { + TitleLogoView(handleHiddenGestureAction: handleHiddenGestureAction) + Spacer() + } + .padding() + + HStack(spacing: 0) { + userInteractiveView(width: geometry.size.width * 0.5) + ReadingIllustrationXXLView(width: geometry.size.width * 0.5) + } + } + } + } + } + + public var body: some View { + primaryContent() + .sheet(isPresented: $showDebugModal) { + DebugMenuView(initialEnvironment: dataService.appEnvironment) + } + .onReceive(Publishers.keyboardHeight) { isKeyboardOnScreen = $0 > 1 } + } +} diff --git a/apple/OmnivoreKit/Sources/Models/AuthFlow.swift b/apple/OmnivoreKit/Sources/Models/AuthFlow.swift new file mode 100644 index 000000000..78dfc11ff --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/AuthFlow.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum AuthFlow { + case signIn + case signUp +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 4cf69272e..12deb1da3 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -1,7 +1,7 @@ import Foundation import Models -public final class DataService { +public final class DataService: ObservableObject { public static var registerIntercomUser: ((String) -> Void)? public static var showIntercomMessenger: (() -> Void)? diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift index e725392f9..2ab5680ef 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift @@ -3,6 +3,8 @@ import SwiftUI import Utils import WebKit +public let readerViewNavBarHeight = 50.0 + #if os(iOS) struct WebAppView: UIViewRepresentable { let request: URLRequest @@ -34,8 +36,8 @@ import WebKit webView.backgroundColor = UIColor.clear webView.configuration.userContentController = contentController webView.scrollView.delegate = context.coordinator - webView.scrollView.contentInset.top = LinkItemDetailView.navBarHeight - webView.scrollView.verticalScrollIndicatorInsets.top = LinkItemDetailView.navBarHeight + webView.scrollView.contentInset.top = readerViewNavBarHeight + webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight for action in WebViewAction.allCases { webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift index 1f54a5fdd..cfe31bc1a 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebAppViewCoordinator.swift @@ -2,7 +2,6 @@ import SwiftUI import WebKit final class WebAppViewCoordinator: NSObject { - let navBarHeight = LinkItemDetailView.navBarHeight var webViewActionHandler: (WKScriptMessage) -> Void = { _ in } var linkHandler: (URL) -> Void = { _ in } var needsReload = true @@ -58,21 +57,21 @@ extension WebAppViewCoordinator: WKNavigationDelegate { let yOffset = scrollView.contentOffset.y if yOffset == 0 { - scrollView.contentInset.top = navBarHeight + scrollView.contentInset.top = readerViewNavBarHeight navBarVisibilityRatio = 1 return } if yOffset < 0 { navBarVisibilityRatio = 1 - scrollView.contentInset.top = navBarHeight + scrollView.contentInset.top = readerViewNavBarHeight return } - if yOffset < navBarHeight { + if yOffset < readerViewNavBarHeight { let isScrollingUp = yOffsetAtStartOfDrag ?? 0 > yOffset - navBarVisibilityRatio = isScrollingUp || yOffset < 0 ? 1 : min(1, 1 - (yOffset / navBarHeight)) - scrollView.contentInset.top = navBarVisibilityRatio * navBarHeight + navBarVisibilityRatio = isScrollingUp || yOffset < 0 ? 1 : min(1, 1 - (yOffset / readerViewNavBarHeight)) + scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight return } @@ -80,21 +79,21 @@ extension WebAppViewCoordinator: WKNavigationDelegate { if yOffset > yOffsetAtStartOfDrag, !isNavBarHidden { let translation = yOffset - yOffsetAtStartOfDrag - let ratio = translation < navBarHeight ? 1 - (translation / navBarHeight) : 0 + let ratio = translation < readerViewNavBarHeight ? 1 - (translation / readerViewNavBarHeight) : 0 navBarVisibilityRatio = min(ratio, 1) - scrollView.contentInset.top = navBarVisibilityRatio * navBarHeight + scrollView.contentInset.top = navBarVisibilityRatio * readerViewNavBarHeight } } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if decelerate, scrollView.contentOffset.y + scrollView.contentInset.top < (yOffsetAtStartOfDrag ?? 0) { - scrollView.contentInset.top = navBarHeight + scrollView.contentInset.top = readerViewNavBarHeight navBarVisibilityRatio = 1 } } func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollView.contentInset.top = navBarHeight + scrollView.contentInset.top = readerViewNavBarHeight navBarVisibilityRatio = 1 return false } diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift index f87b67573..6f704e6b1 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebAppWrapperView.swift @@ -14,8 +14,8 @@ public final class WebAppWrapperViewModel: ObservableObject { let baseURL: URL let rawAuthCookie: String? - @Published var sendIncreaseFontSignal: Bool = false - @Published var sendDecreaseFontSignal: Bool = false + @Published public var sendIncreaseFontSignal: Bool = false + @Published public var sendDecreaseFontSignal: Bool = false public init(webViewURLRequest: URLRequest, baseURL: URL, rawAuthCookie: String?) { self.webViewURLRequest = webViewURLRequest diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/AppleSignInButton.swift b/apple/OmnivoreKit/Sources/Views/Buttons/AppleSignInButton.swift index 2383a6251..b8381201f 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/AppleSignInButton.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/AppleSignInButton.swift @@ -1,12 +1,16 @@ import AuthenticationServices import SwiftUI -struct AppleSignInButton: View { +public struct AppleSignInButton: View { @Environment(\.colorScheme) var colorScheme let onCompletion: (Result) -> Void - var body: some View { + public init(onCompletion: @escaping (Result) -> Void) { + self.onCompletion = onCompletion + } + + public var body: some View { SignInWithAppleButton( .continue, onRequest: { request in diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift index 634d74045..64f72fcb7 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/ButtonStyles.swift @@ -1,17 +1,17 @@ import SwiftUI -struct SolidCapsuleButtonStyle: ButtonStyle { +public struct SolidCapsuleButtonStyle: ButtonStyle { let backgroundColor: Color let textColor: Color let width: CGFloat - init(color: Color = .blue, textColor: Color = .appGrayTextContrast, width: CGFloat = 220) { + public init(color: Color = .blue, textColor: Color = .appGrayTextContrast, width: CGFloat = 220) { self.backgroundColor = color self.textColor = textColor self.width = width } - func makeBody(configuration: Configuration) -> some View { + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.appBody) .foregroundColor(textColor) diff --git a/apple/OmnivoreKit/Sources/Views/Buttons/GoogleAuthButton.swift b/apple/OmnivoreKit/Sources/Views/Buttons/GoogleAuthButton.swift index d76d69df4..9296252c7 100644 --- a/apple/OmnivoreKit/Sources/Views/Buttons/GoogleAuthButton.swift +++ b/apple/OmnivoreKit/Sources/Views/Buttons/GoogleAuthButton.swift @@ -1,9 +1,13 @@ import SwiftUI -struct GoogleAuthButton: View { +public struct GoogleAuthButton: View { let tapAction: () -> Void - var body: some View { + public init(tapAction: @escaping () -> Void) { + self.tapAction = tapAction + } + + public var body: some View { Button(action: tapAction) { HStack(spacing: 8) { Image.googleIcon diff --git a/apple/OmnivoreKit/Sources/Views/DebugMenuView.swift b/apple/OmnivoreKit/Sources/Views/DebugMenuView.swift deleted file mode 100644 index c62b471ba..000000000 --- a/apple/OmnivoreKit/Sources/Views/DebugMenuView.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Combine -import Models -import SwiftUI - -public final class DebugMenuViewModel { - public enum Action { - case applyChanges(environment: DebugMenuEnvOption) - } - - let initialEnvironment: AppEnvironment - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init(initialEnvironment: AppEnvironment) { - self.initialEnvironment = initialEnvironment - } - - var initialDebugMenuEnvOption: DebugMenuEnvOption { - DebugMenuEnvOption.make(from: initialEnvironment) - } -} - -public enum DebugMenuEnvOption: String, CaseIterable { - case dev = "Dev" - case prod = "Prod" - case demo = "Demo" - case local = "Local" - - static func make(from appEnvironment: AppEnvironment) -> DebugMenuEnvOption { - switch appEnvironment { - case .local: - return .local - case .dev: - return .dev - case .prod: - return .prod - case .demo: - return .demo - case .test: - return .local - } - } -} - -public struct DebugMenuView: View { - @State private var selectedEnvironment: DebugMenuEnvOption - - private var viewModel: DebugMenuViewModel - - public init(viewModel: DebugMenuViewModel) { - self.viewModel = viewModel - self._selectedEnvironment = State(initialValue: viewModel.initialDebugMenuEnvOption) - } - - public var body: some View { - VStack { - Text("Debug Menu") - .font(.appTitle) - Form { - Text("API Environment:") - Picker(selection: $selectedEnvironment, label: Text("API Environment:")) { - ForEach(DebugMenuEnvOption.allCases, id: \.self) { - Text($0.rawValue) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - - Button( - action: { viewModel.performActionSubject.send(.applyChanges(environment: selectedEnvironment)) }, - label: { Text("Apply Changes") } - ) - .buttonStyle(SolidCapsuleButtonStyle(width: 220)) - } - .padding() - } -} diff --git a/apple/OmnivoreKit/Sources/Views/FontSizeAdjustmentPopoverView.swift b/apple/OmnivoreKit/Sources/Views/FontSizeAdjustmentPopoverView.swift index deff8c4f8..76ec5520e 100644 --- a/apple/OmnivoreKit/Sources/Views/FontSizeAdjustmentPopoverView.swift +++ b/apple/OmnivoreKit/Sources/Views/FontSizeAdjustmentPopoverView.swift @@ -5,6 +5,14 @@ public struct FontSizeAdjustmentPopoverView: View { let increaseFontAction: () -> Void let decreaseFontAction: () -> Void + public init( + increaseFontAction: @escaping () -> Void, + decreaseFontAction: @escaping () -> Void + ) { + self.increaseFontAction = increaseFontAction + self.decreaseFontAction = decreaseFontAction + } + static let preferredWebFontSizeKey = UserDefaultKey.preferredWebFontSize.rawValue #if os(macOS) @AppStorage(preferredWebFontSizeKey) var storedFontSize = Int(NSFont.userFont(ofSize: 16)?.pointSize ?? 16) diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift similarity index 90% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift rename to apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift index ff7858469..310759e6c 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift @@ -1,10 +1,14 @@ import Models import SwiftUI -struct FeedCard: View { +public struct FeedCard: View { let item: FeedItem - var body: some View { + public init(item: FeedItem) { + self.item = item + } + + public var body: some View { HStack(alignment: .top, spacing: 6) { VStack(alignment: .leading, spacing: 6) { Text(item.title) diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index 9f9526a5a..e612a4afa 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -1,6 +1,6 @@ import SwiftUI -extension Image { +public extension Image { static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) } static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) } static var readingIllustration: Image { Image("_readingIllustration", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift b/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift index bbac86b51..ca0e42a3a 100644 --- a/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift +++ b/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift @@ -5,7 +5,7 @@ import SwiftUI import UIKit #endif -extension Publishers { +public extension Publishers { static var keyboardHeight: AnyPublisher { #if os(iOS) let willShow = NotificationCenter.default diff --git a/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/PDFWrapperView.swift b/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/PDFWrapperView.swift index 1cee80cb8..9ca637bc6 100644 --- a/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/PDFWrapperView.swift +++ b/apple/OmnivoreKit/Sources/Views/LinkedItemDetail/PDFWrapperView.swift @@ -2,10 +2,14 @@ import PDFKit import SwiftUI #if os(macOS) - struct PDFWrapperView: NSViewRepresentable { + public struct PDFWrapperView: NSViewRepresentable { let pdfURL: URL - func makeNSView(context _: Context) -> PDFView { + public init(pdfURL: URL) { + self.pdfURL = pdfURL + } + + public func makeNSView(context _: Context) -> PDFView { let pdfView = PDFView() if let document = PDFDocument(url: pdfURL) { pdfView.document = document @@ -13,7 +17,7 @@ import SwiftUI return pdfView } - func updateNSView(_: PDFView, context _: Context) {} + public func updateNSView(_: PDFView, context _: Context) {} } #endif diff --git a/apple/OmnivoreKit/Sources/Views/LocalText.swift b/apple/OmnivoreKit/Sources/Views/LocalText.swift index 8bf363319..3e7b9d015 100644 --- a/apple/OmnivoreKit/Sources/Views/LocalText.swift +++ b/apple/OmnivoreKit/Sources/Views/LocalText.swift @@ -1,6 +1,6 @@ import Foundation -struct LocalText { +public enum LocalText { static func localText(key: String, comment: String? = nil) -> String { NSLocalizedString(key, bundle: .module, comment: comment ?? "no comment provided by developer") } @@ -9,7 +9,7 @@ struct LocalText { static let googleAuthButton = localText(key: "googleAuthButton") static let registrationViewSignInHeadline = localText(key: "registrationViewSignInHeadline") static let registrationViewSignUpHeadline = localText(key: "registrationViewSignUpHeadline") - static let registrationViewHeadline = localText(key: "registrationViewHeadline") + public static let registrationViewHeadline = localText(key: "registrationViewHeadline") static let networkError = localText(key: "error.network") static let genericError = localText(key: "error.generic") static let invalidCredsLoginError = localText(key: "loginError.invalidCreds") diff --git a/apple/OmnivoreKit/Sources/Views/Popover.swift b/apple/OmnivoreKit/Sources/Views/Popover.swift index 91d9dfd2f..5cba95786 100644 --- a/apple/OmnivoreKit/Sources/Views/Popover.swift +++ b/apple/OmnivoreKit/Sources/Views/Popover.swift @@ -1,7 +1,7 @@ import SwiftUI #if os(iOS) - extension View { + public extension View { func fittedPopover( isPresented: Binding, onDismiss: (() -> Void)? = nil, diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift b/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift deleted file mode 100644 index 13919d14e..000000000 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/PrimaryContentCategory.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI - -public enum PrimaryContentCategory: Identifiable, Hashable, Equatable { - case feed(viewModel: HomeFeedViewModel) - case profile(viewModel: ProfileContainerViewModel) - - public static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool { - lhs.id == rhs.id - } - - public var id: String { - title - } - - var title: String { - switch self { - case .feed: - return "Home" - case .profile: - return "Profile" - } - } - - var image: Image { - switch self { - case .feed: - return .homeTab - case .profile: - return .profileTab - } - } - - var selectedImage: Image { - switch self { - case .feed: - return .homeTabSelected - case .profile: - return .profileTabSelected - } - } - - public var listLabel: some View { - Label { Text(title) } icon: { image.renderingMode(.template) } - } - - @ViewBuilder public var destinationView: some View { - switch self { - case let .feed(viewModel: viewModel): - HomeFeedView(viewModel: viewModel) - case let .profile(viewModel: viewModel): - ProfileContainerView(viewModel: viewModel) - } - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} diff --git a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileCard.swift b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift similarity index 90% rename from apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileCard.swift rename to apple/OmnivoreKit/Sources/Views/ProfileCard.swift index b2d415805..a27ad3857 100644 --- a/apple/OmnivoreKit/Sources/Views/PrimaryContainerViews/Profile/ProfileCard.swift +++ b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift @@ -12,10 +12,14 @@ public struct ProfileCardData { } } -struct ProfileCard: View { +public struct ProfileCard: View { let data: ProfileCardData - var body: some View { + public init(data: ProfileCardData) { + self.data = data + } + + public var body: some View { HStack(alignment: .center) { Group { if let url = data.imageURL { diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift index a475ee0f9..4e3ccf36c 100644 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift +++ b/apple/OmnivoreKit/Sources/Views/RegistrationViews/LoginErrorMessageView.swift @@ -1,10 +1,14 @@ import Models import SwiftUI -struct LoginErrorMessageView: View { +public struct LoginErrorMessageView: View { let loginError: LoginError - var body: some View { + public init(loginError: LoginError) { + self.loginError = loginError + } + + public var body: some View { Text(loginError.message) .font(.appBody) .foregroundColor(.red) diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationHeroImageView.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationHeroImageView.swift index 5f8bccb50..e57096d91 100644 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationHeroImageView.swift +++ b/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationHeroImageView.swift @@ -1,9 +1,13 @@ import SwiftUI -struct RegistrationHeroImageView: View { +public struct RegistrationHeroImageView: View { let tapGestureHandler: () -> Void - var body: some View { + public init(tapGestureHandler: @escaping () -> Void) { + self.tapGestureHandler = tapGestureHandler + } + + public var body: some View { ZStack(alignment: .topLeading) { Image.readingIllustration .resizable() diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationView.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationView.swift deleted file mode 100644 index 528cb744c..000000000 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationView.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Combine -import Models -import SwiftUI -import Utils - -public struct RegistrationView: View { - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @ObservedObject private var viewModel: RegistrationViewModel - - public init(viewModel: RegistrationViewModel) { - self.viewModel = viewModel - } - - var authenticationView: some View { - VStack(spacing: 0) { - VStack(spacing: 28) { - if horizontalSizeClass == .regular { - Spacer() - } - - VStack(alignment: .center, spacing: 16) { - Text(LocalText.registrationViewHeadline) - .font(.appTitle) - .multilineTextAlignment(.center) - .padding(.bottom, horizontalSizeClass == .compact ? 0 : 50) - .padding(.top, horizontalSizeClass == .compact ? 30 : 0) - - AppleSignInButton { - viewModel.performActionSubject.send(.appleSignInCompleted(result: $0)) - } - - if AppKeys.sharedInstance?.iosClientGoogleId != nil { - GoogleAuthButton { - viewModel.performActionSubject.send(.googleButtonTapped) - } - } - } - - if let loginError = viewModel.loginError { - LoginErrorMessageView(loginError: loginError) - } - - Spacer() - } - .frame(maxWidth: 316) - .padding(.horizontal, 16) - } - } - - public var body: some View { - if let createProfileViewModel = viewModel.createProfileViewModel { - CreateProfileView(viewModel: createProfileViewModel) - } else if let newAppleSignupViewModel = viewModel.newAppleSignupViewModel { - NewAppleSignupView(viewModel: newAppleSignupViewModel) - } else { - authenticationView - } - } -} diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationViewModel.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationViewModel.swift deleted file mode 100644 index 1843ec0c7..000000000 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/RegistrationViewModel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import AuthenticationServices -import Combine -import Models -import SwiftUI - -enum AuthFlow { - case signIn - case signUp -} - -public final class RegistrationViewModel: ObservableObject { - @Published public var loginError: LoginError? - @Published public var createProfileViewModel: CreateProfileViewModel? - @Published public var newAppleSignupViewModel: NewAppleSignupViewModel? - - public var debugMenuViewModel: DebugMenuViewModel? - - public enum Action { - case googleButtonTapped - case appleSignInCompleted(result: Result) - } - - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init() {} -} diff --git a/apple/OmnivoreKit/Sources/Views/RegistrationViews/ToggleAuthFlowButton.swift b/apple/OmnivoreKit/Sources/Views/RegistrationViews/ToggleAuthFlowButton.swift index a62c9feb8..f7c417fd3 100644 --- a/apple/OmnivoreKit/Sources/Views/RegistrationViews/ToggleAuthFlowButton.swift +++ b/apple/OmnivoreKit/Sources/Views/RegistrationViews/ToggleAuthFlowButton.swift @@ -1,3 +1,4 @@ +import Models import SwiftUI struct ToggleAuthFlowButton: View { diff --git a/apple/OmnivoreKit/Sources/Views/SnoozeView.swift b/apple/OmnivoreKit/Sources/Views/SnoozeView.swift index 7901412cf..8c23a926e 100644 --- a/apple/OmnivoreKit/Sources/Views/SnoozeView.swift +++ b/apple/OmnivoreKit/Sources/Views/SnoozeView.swift @@ -1,12 +1,22 @@ import Models import SwiftUI -struct SnoozeView: View { +public struct SnoozeView: View { @Binding var snoozePresented: Bool @Binding var itemToSnooze: FeedItem? let snoozeAction: (SnoozeActionParams) -> Void - var body: some View { + public init( + snoozePresented: Binding, + itemToSnooze: Binding, + snoozeAction: @escaping (SnoozeActionParams) -> Void + ) { + self._snoozePresented = snoozePresented + self._itemToSnooze = itemToSnooze + self.snoozeAction = snoozeAction + } + + public var body: some View { VStack { Spacer() @@ -42,10 +52,10 @@ struct SnoozeView: View { } } -struct SnoozeActionParams { - let feedItemId: String - let snoozeUntilDate: Date - let successMessage: String? +public struct SnoozeActionParams { + public let feedItemId: String + public let snoozeUntilDate: Date + public let successMessage: String? } private struct SnoozeIconButtonView: View { diff --git a/apple/OmnivoreKit/Sources/Views/TextFields/TextFieldStyles.swift b/apple/OmnivoreKit/Sources/Views/TextFields/TextFieldStyles.swift index 49be3cb30..6f4a54c78 100644 --- a/apple/OmnivoreKit/Sources/Views/TextFields/TextFieldStyles.swift +++ b/apple/OmnivoreKit/Sources/Views/TextFields/TextFieldStyles.swift @@ -1,8 +1,9 @@ import SwiftUI -struct StandardTextFieldStyle: TextFieldStyle { +public struct StandardTextFieldStyle: TextFieldStyle { + public init() {} // swiftlint:disable:next identifier_name - func _body(configuration: TextField<_Label>) -> some View { + public func _body(configuration: TextField<_Label>) -> some View { configuration .textFieldStyle(PlainTextFieldStyle()) .multilineTextAlignment(.leading) @@ -13,7 +14,7 @@ struct StandardTextFieldStyle: TextFieldStyle { .background(border) } - var border: some View { + public var border: some View { RoundedRectangle(cornerRadius: 16) .strokeBorder(Color.appGrayBorder, lineWidth: 1) .background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground)) diff --git a/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift b/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift index 247860487..1b6d589ca 100644 --- a/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift +++ b/apple/OmnivoreKit/Sources/Views/UserSettings/ManageAccountView.swift @@ -1,10 +1,14 @@ import SwiftUI -struct ManageAccountView: View { +public struct ManageAccountView: View { let handleAccountDeletion: () -> Void @State private var showDeleteAccountConfirmation = false - var body: some View { + public init(handleAccountDeletion: @escaping () -> Void) { + self.handleAccountDeletion = handleAccountDeletion + } + + public var body: some View { Button( action: { showDeleteAccountConfirmation = true diff --git a/apple/OmnivoreKit/Sources/Views/Utils/MacOSSizeClass.swift b/apple/OmnivoreKit/Sources/Views/Utils/MacOSSizeClass.swift index 85c3adff9..f53d08f91 100644 --- a/apple/OmnivoreKit/Sources/Views/Utils/MacOSSizeClass.swift +++ b/apple/OmnivoreKit/Sources/Views/Utils/MacOSSizeClass.swift @@ -8,20 +8,20 @@ import SwiftUI // https://stackoverflow.com/questions/63526478/swiftui-userinterfacesizeclass-for-universal-macos-ios-views #if os(macOS) - enum UserInterfaceSizeClass { + public enum UserInterfaceSizeClass { case compact case regular } - struct HorizontalSizeClassEnvironmentKey: EnvironmentKey { - static let defaultValue: UserInterfaceSizeClass = .regular + public struct HorizontalSizeClassEnvironmentKey: EnvironmentKey { + public static let defaultValue: UserInterfaceSizeClass = .regular } - struct VerticalSizeClassEnvironmentKey: EnvironmentKey { - static let defaultValue: UserInterfaceSizeClass = .regular + public struct VerticalSizeClassEnvironmentKey: EnvironmentKey { + public static let defaultValue: UserInterfaceSizeClass = .regular } - extension EnvironmentValues { + public extension EnvironmentValues { var horizontalSizeClass: UserInterfaceSizeClass { get { self[HorizontalSizeClassEnvironmentKey.self] } set { self[HorizontalSizeClassEnvironmentKey.self] = newValue } diff --git a/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift b/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift index ae4231c4a..370760116 100644 --- a/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift +++ b/apple/OmnivoreKit/Sources/Views/Web/BasicWebAppView.swift @@ -2,22 +2,26 @@ import SwiftUI import WebKit #if os(iOS) - struct BasicWebAppView: UIViewRepresentable { + public struct BasicWebAppView: UIViewRepresentable { let request: URLRequest let webView = WKWebView() - func makeCoordinator() -> BasicWebAppViewCoordinator { + public init(request: URLRequest) { + self.request = request + } + + public func makeCoordinator() -> BasicWebAppViewCoordinator { BasicWebAppViewCoordinator() } - func makeUIView(context _: Context) -> WKWebView { + public func makeUIView(context _: Context) -> WKWebView { webView.scrollView.isScrollEnabled = true webView.isOpaque = false webView.backgroundColor = UIColor.clear return webView } - func updateUIView(_ webView: WKWebView, context: Context) { + public func updateUIView(_ webView: WKWebView, context: Context) { if context.coordinator.needsReload { webView.load(request) context.coordinator.needsReload = false @@ -27,18 +31,22 @@ import WebKit #endif #if os(macOS) - struct BasicWebAppView: NSViewRepresentable { + public struct BasicWebAppView: NSViewRepresentable { let request: URLRequest - func makeCoordinator() -> BasicWebAppViewCoordinator { + public init(request: URLRequest) { + self.request = request + } + + public func makeCoordinator() -> BasicWebAppViewCoordinator { BasicWebAppViewCoordinator() } - func makeNSView(context _: Context) -> WKWebView { + public func makeNSView(context _: Context) -> WKWebView { WebView(frame: CGRect.zero) } - func updateNSView(_ webView: WKWebView, context: Context) { + public func updateNSView(_ webView: WKWebView, context: Context) { if context.coordinator.needsReload { webView.load(request) context.coordinator.needsReload = false @@ -47,7 +55,7 @@ import WebKit } #endif -final class BasicWebAppViewCoordinator: NSObject { +public final class BasicWebAppViewCoordinator: NSObject { var needsReload = true override init() { diff --git a/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeView.swift b/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeView.swift deleted file mode 100644 index a85d46a16..000000000 --- a/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeView.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Combine -import Models -import SwiftUI - -public final class WelcomeViewModel: ObservableObject { - @Published public var showDebugModal: Bool = false - @Published public var showRegistrationModal: Bool = false - - public let registrationViewModel: RegistrationViewModel - public var debugMenuViewModel: DebugMenuViewModel? - - public enum Action { - case hiddenGesturePerformed - } - - public var subscriptions = Set() - public let performActionSubject = PassthroughSubject() - - public init(registrationViewModel: RegistrationViewModel) { - self.registrationViewModel = registrationViewModel - } -} - -public struct WelcomeView: View { - @Environment(\.horizontalSizeClass) var horizontalSizeClass - @ObservedObject private var viewModel: WelcomeViewModel - @State private var showRegistrationView = false - @State private var isKeyboardOnScreen = false - - public init(viewModel: WelcomeViewModel) { - self.viewModel = viewModel - } - - @ViewBuilder func userInteractiveView(width: CGFloat) -> some View { - Group { - if showRegistrationView { - RegistrationView(viewModel: viewModel.registrationViewModel) - } else { - getStartedView - } - } - .frame(width: width) - .zIndex(2) - } - - var titleLogo: some View { - Image.omnivoreTitleLogo - .renderingMode(.template) - .foregroundColor(.appGrayTextContrast) - .frame(height: 40) - .gesture( - TapGesture(count: 2) - .onEnded { - viewModel.performActionSubject.send(.hiddenGesturePerformed) - } - ) - } - - var getStartedView: some View { - HStack { - VStack(alignment: .leading, spacing: 32) { - Text("A better social\nreading experience\nstarts with Omnivore.") - .font(.appTitle) - .multilineTextAlignment(.leading) - - BorderedButton(color: .appGrayTextContrast, text: "Get Started") { - showRegistrationView = true - } - .frame(width: 220) - } - .padding(.leading, horizontalSizeClass == .compact ? 16 : 80) - .padding(.top, horizontalSizeClass == .compact ? 16 : 0) - - Spacer() - } - } - - @ViewBuilder func splitColorBackground(width: CGFloat) -> some View { - HStack(spacing: 0) { - Color.systemBackground.frame(width: width * 0.5) - Color.appBackground.frame(width: width * 0.5) - } - .edgesIgnoringSafeArea(.all) - } - - @ViewBuilder func largeBackgroundImage(width: CGFloat) -> some View { - Image.readingIllustrationXXL - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: width) - .clipped() - .edgesIgnoringSafeArea([.vertical, .trailing]) - } - - @ViewBuilder func primaryContent() -> some View { - if horizontalSizeClass == .compact { - GeometryReader { geometry in - ZStack(alignment: .leading) { - Color.systemBackground - .edgesIgnoringSafeArea(.all) - - if geometry.size.width < geometry.size.height, !isKeyboardOnScreen { - VStack { - Color.appDeepBackground.frame(height: 100) - Spacer() - } - .edgesIgnoringSafeArea(.all) - } - - VStack { - if geometry.size.width < geometry.size.height, !isKeyboardOnScreen { - RegistrationHeroImageView( - tapGestureHandler: { viewModel.performActionSubject.send(.hiddenGesturePerformed) } - ) - } - userInteractiveView(width: geometry.size.width) - Spacer() - } - } - } - } else { - GeometryReader { geometry in - ZStack(alignment: .leading) { - splitColorBackground(width: geometry.size.width) - - VStack { - titleLogo - Spacer() - } - .padding() - - HStack(spacing: 0) { - userInteractiveView(width: geometry.size.width * 0.5) - largeBackgroundImage(width: geometry.size.width * 0.5) - } - } - } - } - } - - public var body: some View { - primaryContent() - .sheet(isPresented: $viewModel.showDebugModal) { - if let debugMenuViewModel = viewModel.debugMenuViewModel { - DebugMenuView(viewModel: debugMenuViewModel) - } - } - .onReceive(Publishers.keyboardHeight) { isKeyboardOnScreen = $0 > 1 } - } -} diff --git a/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeViewComponents.swift b/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeViewComponents.swift new file mode 100644 index 000000000..f4532c1b9 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/WelcomeView/WelcomeViewComponents.swift @@ -0,0 +1,83 @@ +import SwiftUI + +public struct ReadingIllustrationXXLView: View { + let width: CGFloat + + public init(width: CGFloat) { + self.width = width + } + + public var body: some View { + Image.readingIllustrationXXL + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width) + .clipped() + .edgesIgnoringSafeArea([.vertical, .trailing]) + } +} + +public struct TitleLogoView: View { + let handleHiddenGestureAction: () -> Void + + public init(handleHiddenGestureAction: @escaping () -> Void) { + self.handleHiddenGestureAction = handleHiddenGestureAction + } + + public var body: some View { + Image.omnivoreTitleLogo + .renderingMode(.template) + .foregroundColor(.appGrayTextContrast) + .frame(height: 40) + .gesture( + TapGesture(count: 2) + .onEnded { + handleHiddenGestureAction() + } + ) + } +} + +public struct GetStartedView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Binding var showRegistrationView: Bool + + public init(showRegistrationView: Binding) { + self._showRegistrationView = showRegistrationView + } + + public var body: some View { + HStack { + VStack(alignment: .leading, spacing: 32) { + Text("A better social\nreading experience\nstarts with Omnivore.") + .font(.appTitle) + .multilineTextAlignment(.leading) + + BorderedButton(color: .appGrayTextContrast, text: "Get Started") { + showRegistrationView = true + } + .frame(width: 220) + } + .padding(.leading, horizontalSizeClass == .compact ? 16 : 80) + .padding(.top, horizontalSizeClass == .compact ? 16 : 0) + + Spacer() + } + } +} + +public struct SplitColorBackground: View { + let width: CGFloat + + public init(width: CGFloat) { + self.width = width + } + + public var body: some View { + HStack(spacing: 0) { + Color.systemBackground.frame(width: width * 0.5) + Color.appBackground.frame(width: width * 0.5) + } + .edgesIgnoringSafeArea(.all) + } +}