Merge pull request #118 from omnivore-app/refactor/swiftui-environment-services

Use EnvironmentObject for Services [apple refactor]
This commit is contained in:
Satindar Dhillon 2022-02-24 07:24:05 -08:00 committed by GitHub
commit 1b215e3438
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 951 additions and 1185 deletions

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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<AnyCancellable>()
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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) {

View file

@ -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()
}
}

View file

@ -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<AnyCancellable>()
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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
}

View file

@ -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<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
var subscriptions = Set<AnyCancellable>()
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<PresentationMode>
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
)
}
}
}

View file

@ -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<AnyCancellable>()
init(userProfile: UserProfile) {
self.userProfile = userProfile
}
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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))

View file

@ -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 {

View file

@ -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<AnyCancellable>()
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<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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()
)

View file

@ -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<AnyCancellable>()
func handleAppleSignInCompletion(result: Result<ASAuthorization, Error>, 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
}

View file

@ -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 }
}
}

View file

@ -0,0 +1,6 @@
import Foundation
public enum AuthFlow {
case signIn
case signUp
}

View file

@ -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)?

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -1,12 +1,16 @@
import AuthenticationServices
import SwiftUI
struct AppleSignInButton: View {
public struct AppleSignInButton: View {
@Environment(\.colorScheme) var colorScheme
let onCompletion: (Result<ASAuthorization, Error>) -> Void
var body: some View {
public init(onCompletion: @escaping (Result<ASAuthorization, Error>) -> Void) {
self.onCompletion = onCompletion
}
public var body: some View {
SignInWithAppleButton(
.continue,
onRequest: { request in

View file

@ -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)

View file

@ -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

View file

@ -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<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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()
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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) }

View file

@ -5,7 +5,7 @@ import SwiftUI
import UIKit
#endif
extension Publishers {
public extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
#if os(iOS)
let willShow = NotificationCenter.default

View file

@ -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

View file

@ -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")

View file

@ -1,7 +1,7 @@
import SwiftUI
#if os(iOS)
extension View {
public extension View {
func fittedPopover<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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()

View file

@ -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
}
}
}

View file

@ -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<ASAuthorization, Error>)
}
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
public init() {}
}

View file

@ -1,3 +1,4 @@
import Models
import SwiftUI
struct ToggleAuthFlowButton: View {

View file

@ -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<Bool>,
itemToSnooze: Binding<FeedItem?>,
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 {

View file

@ -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))

View file

@ -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

View file

@ -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 }

View file

@ -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() {

View file

@ -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<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
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 }
}
}

View file

@ -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<Bool>) {
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)
}
}