mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #118 from omnivore-app/refactor/swiftui-environment-services
Use EnvironmentObject for Services [apple refactor]
This commit is contained in:
commit
1b215e3438
50 changed files with 951 additions and 1185 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
42
apple/OmnivoreKit/Sources/Binders/Views/DebugMenuView.swift
Normal file
42
apple/OmnivoreKit/Sources/Binders/Views/DebugMenuView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
156
apple/OmnivoreKit/Sources/Binders/Views/RegistrationView.swift
Normal file
156
apple/OmnivoreKit/Sources/Binders/Views/RegistrationView.swift
Normal 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
|
||||
}
|
||||
83
apple/OmnivoreKit/Sources/Binders/Views/WelcomeView.swift
Normal file
83
apple/OmnivoreKit/Sources/Binders/Views/WelcomeView.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
6
apple/OmnivoreKit/Sources/Models/AuthFlow.swift
Normal file
6
apple/OmnivoreKit/Sources/Models/AuthFlow.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
public enum AuthFlow {
|
||||
case signIn
|
||||
case signUp
|
||||
}
|
||||
|
|
@ -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)?
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
extension View {
|
||||
public extension View {
|
||||
func fittedPopover<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleAuthFlowButton: View {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue