Merge pull request #125 from omnivore-app/refactor/isolate-apple-view-code

Isolate apple view code [Refactor]
This commit is contained in:
Satindar Dhillon 2022-02-24 20:26:41 -08:00 committed by GitHub
commit ff2aba0768
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 354 additions and 331 deletions

View file

@ -8,9 +8,9 @@ import Views
public extension PlatformViewController {
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
let viewModel = ShareExtensionViewModel.make(extensionContext: extensionContext)
let rootView = ShareExtensionView(viewModel: viewModel)
let hostingController = PlatformHostingController(rootView: rootView)
let hostingController = PlatformHostingController(
rootView: ShareExtensionView(extensionContext: extensionContext)
)
#if os(iOS)
hostingController.view.layer.cornerRadius = 12
hostingController.view.layer.masksToBounds = true
@ -20,42 +20,35 @@ public extension PlatformViewController {
}
}
extension ShareExtensionViewModel {
static func make(extensionContext: NSExtensionContext?) -> ShareExtensionViewModel {
let viewModel = ShareExtensionViewModel()
viewModel.bind(extensionContext: extensionContext)
return viewModel
final class ShareExtensionViewModel: ObservableObject {
let extensionContext: NSExtensionContext?
@Published var title: String?
@Published var status = ShareExtensionStatus.successfullySaved
@Published var debugText: String?
var subscriptions = Set<AnyCancellable>()
let requestID = UUID().uuidString.lowercased()
init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext
}
func bind(extensionContext: NSExtensionContext?) {
performActionSubject.sink { [weak self] action in
switch action {
case let .savePage(requestID):
self?.savePage(extensionContext: extensionContext, requestId: requestID)
case .dismissButtonTapped:
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
case .copyLinkButtonTapped:
print("copy link button tapped")
case .readNowButtonTapped:
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(self?.requestID ?? "")")
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
}
#endif
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
case .archiveButtonTapped:
print("archive button tapped")
func handleReadNowAction() {
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestID)")
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
}
}
.store(in: &subscriptions)
#endif
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
private func savePage(extensionContext: NSExtensionContext?, requestId: String) {
func savePage() {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
switch result {
case let .success(payload):
self?.persist(pageScrapePayload: payload, requestId: requestId)
self?.persist(pageScrapePayload: payload, requestId: self?.requestID ?? "")
case let .failure(error):
self?.debugText = error.message
}
@ -100,3 +93,24 @@ extension ShareExtensionViewModel {
.store(in: &subscriptions)
}
}
struct ShareExtensionView: View {
@ObservedObject private var viewModel: ShareExtensionViewModel
init(extensionContext: NSExtensionContext?) {
self.viewModel = ShareExtensionViewModel(extensionContext: extensionContext)
}
var body: some View {
ShareExtensionChildView(
debugText: viewModel.debugText,
title: viewModel.title,
status: viewModel.status,
onAppearAction: viewModel.savePage,
readNowButtonAction: viewModel.handleReadNowAction,
dismissButtonTappedAction: { _, _ in
viewModel.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
)
}
}

View file

@ -20,10 +20,7 @@ public final class RootViewModel: ObservableObject {
@Published fileprivate var snackbarMessage: String?
@Published fileprivate var showSnackbar = false
public enum Action {}
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
public init() {
registerFonts()

View file

@ -0,0 +1,126 @@
import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
final class CreateProfileViewModel: ObservableObject {
let initialUserProfile: UserProfile
var hasSuggestedProfile: Bool {
!(initialUserProfile.name.isEmpty && initialUserProfile.username.isEmpty)
}
var headlineText: String {
hasSuggestedProfile ? "Confirm Your Profile" : "Create Your Profile"
}
var submitButtonText: String {
hasSuggestedProfile ? "Confirm" : "Submit"
}
@Published var loginError: LoginError?
@Published var validationErrorMessage: String?
@Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername
@Published var potentialUsername: String
var subscriptions = Set<AnyCancellable>()
init(initialUserProfile: UserProfile, dataService: DataService) {
self.initialUserProfile = initialUserProfile
self.potentialUsername = initialUserProfile.username
$potentialUsername
.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] username in
self?.validateUsername(username: username, dataService: dataService)
})
.store(in: &subscriptions)
}
func submitProfile(name: String, bio: String, authenticator: Authenticator) {
let profileOrError = UserProfile.make(
username: potentialUsername,
name: name,
bio: bio.isEmpty ? nil : bio
)
switch profileOrError {
case let .left(userProfile):
submitProfile(userProfile: userProfile, authenticator: authenticator)
case let .right(errorMessage):
validationErrorMessage = errorMessage
}
}
func validateUsername(username: String, dataService: DataService) {
if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) {
potentialUsernameStatus = status
return
}
dataService.validateUsernamePublisher(username: username).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(usernameError) = completion else { return }
switch usernameError {
case .tooShort:
self?.potentialUsernameStatus = .tooShort
case .tooLong:
self?.potentialUsernameStatus = .tooLong
case .invalidPattern:
self?.potentialUsernameStatus = .invalidPattern
case .nameUnavailable:
self?.potentialUsernameStatus = .unavailable
case .internalServer, .unknown:
self?.loginError = .unknown
case .network:
self?.loginError = .network
}
},
receiveValue: { [weak self] in
self?.potentialUsernameStatus = .available
}
)
.store(in: &subscriptions)
}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
}
struct CreateProfileContainerView: View {
@EnvironmentObject var authenticator: Authenticator
@EnvironmentObject var dataService: DataService
@ObservedObject private var viewModel: CreateProfileViewModel
@State private var name: String
@State private var bio = ""
init(userProfile: UserProfile, dataService: DataService) {
self.viewModel = CreateProfileViewModel(initialUserProfile: userProfile, dataService: dataService)
self._name = State(initialValue: userProfile.name)
}
var body: some View {
CreateProfileView(
name: $name,
potentialUsername: $viewModel.potentialUsername,
headlineText: viewModel.headlineText,
submitButtonText: viewModel.submitButtonText,
validationErrorMessage: viewModel.validationErrorMessage,
loginError: viewModel.loginError,
potentialUsernameStatus: viewModel.potentialUsernameStatus,
submitProfileAction: { viewModel.submitProfile(name: $0, bio: $1, authenticator: authenticator) }
)
}
}

View file

@ -1,231 +0,0 @@
import Combine
import Models
import Services
import SwiftUI
import Utils
import Views
final class CreateProfileViewModel: ObservableObject {
let initialUserProfile: UserProfile
var hasSuggestedProfile: Bool {
!(initialUserProfile.name.isEmpty && initialUserProfile.username.isEmpty)
}
var headlineText: String {
hasSuggestedProfile ? "Confirm Your Profile" : "Create Your Profile"
}
var submitButtonText: String {
hasSuggestedProfile ? "Confirm" : "Submit"
}
@Published var loginError: LoginError?
@Published var validationErrorMessage: String?
@Published var potentialUsernameStatus = PotentialUsernameStatus.noUsername
@Published var potentialUsername: String
var subscriptions = Set<AnyCancellable>()
init(initialUserProfile: UserProfile, dataService: DataService) {
self.initialUserProfile = initialUserProfile
self.potentialUsername = initialUserProfile.username
$potentialUsername
.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] username in
self?.validateUsername(username: username, dataService: dataService)
})
.store(in: &subscriptions)
}
func submitProfile(name: String, bio: String, authenticator: Authenticator) {
let profileOrError = UserProfile.make(
username: potentialUsername,
name: name,
bio: bio.isEmpty ? nil : bio
)
switch profileOrError {
case let .left(userProfile):
submitProfile(userProfile: userProfile, authenticator: authenticator)
case let .right(errorMessage):
validationErrorMessage = errorMessage
}
}
func validateUsername(username: String, dataService: DataService) {
if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) {
potentialUsernameStatus = status
return
}
dataService.validateUsernamePublisher(username: username).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(usernameError) = completion else { return }
switch usernameError {
case .tooShort:
self?.potentialUsernameStatus = .tooShort
case .tooLong:
self?.potentialUsernameStatus = .tooLong
case .invalidPattern:
self?.potentialUsernameStatus = .invalidPattern
case .nameUnavailable:
self?.potentialUsernameStatus = .unavailable
case .internalServer, .unknown:
self?.loginError = .unknown
case .network:
self?.loginError = .network
}
},
receiveValue: { [weak self] in
self?.potentialUsernameStatus = .available
}
)
.store(in: &subscriptions)
}
func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
}
struct 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 = ""
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, authenticator: authenticator)
}
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 28) {
ScrollView(showsIndicators: false) {
if horizontalSizeClass == .regular {
Spacer(minLength: 150)
}
VStack(alignment: .center, spacing: 16) {
Text(viewModel.headlineText)
.font(.appTitle)
.multilineTextAlignment(.center)
.padding(.bottom, horizontalSizeClass == .compact ? 0 : 50)
.padding(.top, horizontalSizeClass == .compact ? 30 : 0)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Name")
.font(.appFootnote)
.foregroundColor(.appGrayText)
#if os(iOS)
TextField("", text: $name)
.textContentType(.name)
.keyboardType(.alphabet)
#elseif os(macOS)
TextField("", text: $name)
#endif
}
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Username")
.font(.appFootnote)
.foregroundColor(.appGrayText)
TextField("", text: $viewModel.potentialUsername)
}
if viewModel.potentialUsernameStatus == .available {
Image(systemName: "checkmark.circle.fill")
.font(.appBody)
.foregroundColor(.green)
}
}
if let message = viewModel.potentialUsernameStatus.message {
Text(message)
.font(.appCaption)
.foregroundColor(.red)
}
}
.animation(.default)
VStack(alignment: .leading, spacing: 6) {
Text("Bio (optional)")
.font(.appFootnote)
.foregroundColor(.appGrayText)
TextEditor(text: $bio)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayText)
.font(.appBody)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground))
)
.frame(height: 160)
}
Button(
action: didTapSubmitButton,
label: { Text(viewModel.submitButtonText) }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
if let errorMessage = viewModel.validationErrorMessage {
Text(errorMessage)
.font(.appCaption)
.foregroundColor(.red)
}
if let loginError = viewModel.loginError, viewModel.validationErrorMessage == nil {
LoginErrorMessageView(loginError: loginError)
}
}
.textFieldStyle(StandardTextFieldStyle())
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer()
}
}
.frame(maxWidth: 300)
}
}
private extension PotentialUsernameStatus {
var message: String? {
switch self {
case .tooShort:
return "Username must contain at least 4 characters"
case .tooLong:
return "Username must be less than 15 characters"
case .invalidPattern:
return "Username can contain only letters and numbers"
case .unavailable:
return "This name is not available"
case .noUsername, .available:
return nil
}
}
}

View file

@ -129,7 +129,7 @@ struct RegistrationView: View {
var body: some View {
if let registrationState = viewModel.registrationState {
if case let RegistrationViewModel.RegistrationState.createProfile(userProfile) = registrationState {
CreateProfileView(userProfile: userProfile, dataService: dataService)
CreateProfileContainerView(userProfile: userProfile, dataService: dataService)
} else if case let RegistrationViewModel.RegistrationState.newAppleSignUp(userProfile) = registrationState {
NewAppleSignupView(
userProfile: userProfile,

View file

@ -0,0 +1,151 @@
import Models
import SwiftUI
import Utils
public struct CreateProfileView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State private var bio = ""
@Binding var name: String
@Binding var potentialUsername: String
let headlineText: String
let submitButtonText: String
let validationErrorMessage: String?
let loginError: LoginError?
let potentialUsernameStatus: PotentialUsernameStatus
let submitProfileAction: (String, String) -> Void
public init(
name: Binding<String>,
potentialUsername: Binding<String>,
headlineText: String,
submitButtonText: String,
validationErrorMessage: String?,
loginError: LoginError?,
potentialUsernameStatus: PotentialUsernameStatus,
submitProfileAction: @escaping (String, String) -> Void
) {
self._name = name
self._potentialUsername = potentialUsername
self.headlineText = headlineText
self.submitButtonText = submitButtonText
self.validationErrorMessage = validationErrorMessage
self.loginError = loginError
self.potentialUsernameStatus = potentialUsernameStatus
self.submitProfileAction = submitProfileAction
}
public var body: some View {
VStack(spacing: 0) {
VStack(spacing: 28) {
ScrollView(showsIndicators: false) {
if horizontalSizeClass == .regular {
Spacer(minLength: 150)
}
VStack(alignment: .center, spacing: 16) {
Text(headlineText)
.font(.appTitle)
.multilineTextAlignment(.center)
.padding(.bottom, horizontalSizeClass == .compact ? 0 : 50)
.padding(.top, horizontalSizeClass == .compact ? 30 : 0)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Name")
.font(.appFootnote)
.foregroundColor(.appGrayText)
#if os(iOS)
TextField("", text: $name)
.textContentType(.name)
.keyboardType(.alphabet)
#elseif os(macOS)
TextField("", text: $name)
#endif
}
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Username")
.font(.appFootnote)
.foregroundColor(.appGrayText)
TextField("", text: $potentialUsername)
}
if potentialUsernameStatus == .available {
Image(systemName: "checkmark.circle.fill")
.font(.appBody)
.foregroundColor(.green)
}
}
if let message = potentialUsernameStatus.message {
Text(message)
.font(.appCaption)
.foregroundColor(.red)
}
}
.animation(.default)
VStack(alignment: .leading, spacing: 6) {
Text("Bio (optional)")
.font(.appFootnote)
.foregroundColor(.appGrayText)
TextEditor(text: $bio)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayText)
.font(.appBody)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.systemBackground))
)
.frame(height: 160)
}
Button(
action: { submitProfileAction(name, bio) },
label: { Text(submitButtonText) }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
if let errorMessage = validationErrorMessage {
Text(errorMessage)
.font(.appCaption)
.foregroundColor(.red)
}
if let loginError = loginError, validationErrorMessage == nil {
LoginErrorMessageView(loginError: loginError)
}
}
.textFieldStyle(StandardTextFieldStyle())
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer()
}
}
.frame(maxWidth: 300)
}
}
private extension PotentialUsernameStatus {
var message: String? {
switch self {
case .tooShort:
return "Username must contain at least 4 characters"
case .tooLong:
return "Username must be less than 15 characters"
case .invalidPattern:
return "Username can contain only letters and numbers"
case .unavailable:
return "This name is not available"
case .noUsername, .available:
return nil
}
}
}

View file

@ -36,26 +36,6 @@ private extension SaveArticleError {
}
}
public final class ShareExtensionViewModel: ObservableObject {
public enum Action {
case savePage(requestID: String)
case copyLinkButtonTapped
case readNowButtonTapped
case archiveButtonTapped
case dismissButtonTapped(reminderTime: ReminderTime?, hideUntilReminded: Bool)
}
@Published public var title: String?
@Published public var status = ShareExtensionStatus.successfullySaved
@Published public var debugText: String?
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
public let requestID = UUID().uuidString.lowercased()
public init() {}
}
struct IconButtonView: View {
let title: String
let systemIconName: String
@ -106,16 +86,33 @@ struct CheckmarkButtonView: View {
}
}
public struct ShareExtensionView: View {
@State private var reminderTime: ReminderTime?
@State private var hideUntilReminded = false
public struct ShareExtensionChildView: View {
let debugText: String?
let title: String?
let status: ShareExtensionStatus
let onAppearAction: () -> Void
let readNowButtonAction: () -> Void
let dismissButtonTappedAction: (ReminderTime?, Bool) -> Void
@ObservedObject private var viewModel: ShareExtensionViewModel
public init(viewModel: ShareExtensionViewModel) {
self.viewModel = viewModel
public init(
debugText: String?,
title: String?,
status: ShareExtensionStatus,
onAppearAction: @escaping () -> Void,
readNowButtonAction: @escaping () -> Void,
dismissButtonTappedAction: @escaping (ReminderTime?, Bool) -> Void
) {
self.debugText = debugText
self.title = title
self.status = status
self.onAppearAction = onAppearAction
self.readNowButtonAction = readNowButtonAction
self.dismissButtonTappedAction = dismissButtonTappedAction
}
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
private var savedStateView: some View {
HStack {
Spacer()
@ -123,7 +120,7 @@ public struct ShareExtensionView: View {
title: "Read Now",
systemIconName: "book",
action: {
viewModel.performActionSubject.send(.readNowButtonTapped)
readNowButtonAction()
}
)
Spacer()
@ -144,12 +141,12 @@ public struct ShareExtensionView: View {
public var body: some View {
VStack(alignment: .leading) {
#if DEBUG
if let debugText = viewModel.debugText {
if let debugText = debugText {
Text(debugText)
}
#endif
if let title = viewModel.title {
if let title = title {
Text(title)
.font(.appHeadline)
.lineLimit(1)
@ -159,7 +156,7 @@ public struct ShareExtensionView: View {
Spacer()
if case ShareExtensionStatus.successfullySaved = viewModel.status {
if case ShareExtensionStatus.successfullySaved = status {
if FeatureFlag.enableReadNowFromShareExtension {
savedStateView
} else {
@ -174,7 +171,7 @@ public struct ShareExtensionView: View {
}
.padding()
}
} else if case let ShareExtensionStatus.failed(error) = viewModel.status {
} else if case let ShareExtensionStatus.failed(error) = status {
HStack {
Spacer()
Text(error.displayMessage)
@ -230,11 +227,7 @@ public struct ShareExtensionView: View {
Button(
action: {
viewModel.performActionSubject
.send(.dismissButtonTapped(
reminderTime: reminderTime,
hideUntilReminded: hideUntilReminded
))
dismissButtonTappedAction(reminderTime, hideUntilReminded)
},
label: {
Text("Dismiss")
@ -251,34 +244,7 @@ public struct ShareExtensionView: View {
alignment: .topLeading
)
.onAppear {
viewModel.performActionSubject.send(.savePage(requestID: viewModel.requestID))
onAppearAction()
}
}
}
#if DEBUG
struct ShareExtensionViewPreview: PreviewProvider {
public struct ContainerView: View {
let shareExtensionViewModel: ShareExtensionViewModel
@State var showExtensionModal = true
public var body: some View {
Button("Show Extension") {
showExtensionModal = true
}
.popover(isPresented: $showExtensionModal) {
ShareExtensionView(viewModel: shareExtensionViewModel)
}
}
}
static var previews: some View {
registerFonts()
let viewModel = ShareExtensionViewModel()
viewModel.status = .successfullySaved
return ShareExtensionView(viewModel: viewModel)
.preferredColorScheme(.dark)
}
}
#endif