From a2dc370a220e7cc1e4600cf01b7013ac4c3ee378 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 27 Jan 2021 12:31:32 -0800 Subject: [PATCH] Sign in overhaul --- Localizations/Localizable.strings | 3 + Metatext.xcodeproj/project.pbxproj | 4 + .../AddIdentityViewController.swift | 298 ++++++++++++++++++ .../View Models/AddIdentityViewModel.swift | 4 +- Views/AddIdentityView.swift | 72 +---- Views/IdentitiesView.swift | 4 +- Views/RegistrationView.swift | 2 +- Views/RootView.swift | 4 +- Views/ViewConstants.swift | 1 + 9 files changed, 325 insertions(+), 67 deletions(-) create mode 100644 View Controllers/AddIdentityViewController.swift diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 9b8492a..b7dcdd2 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -30,8 +30,11 @@ "add-identity.browse" = "Browse"; "add-identity.instance-not-supported" = "In order to provide a safe experience to all users and comply with the App Store Review Guidelines, this instance is not supported."; "add-identity.join" = "Join"; +"add-identity.prompt" = "Enter the URL of the Mastodon instance you wish to connect to:"; "add-identity.request-invite" = "Request an invite"; "add-identity.unable-to-connect-to-instance" = "Unable to connect to instance"; +"add-identity.welcome" = "Welcome to Metatext"; +"add-identity.what-is-mastodon" = "What is Mastodon?"; "attachment.edit.description" = "Describe for the visually impaired"; "attachment.edit.description.audio" = "Describe for people with hearing loss"; "attachment.edit.description.video" = "Describe for people with hearing loss or visual impairment"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 7b27686..0a19adb 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -154,6 +154,7 @@ D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.swift */; }; D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */; }; D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */; }; + D0F4362D25C10B9600E4F896 /* AddIdentityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F4362C25C10B9600E4F896 /* AddIdentityViewController.swift */; }; D0F5880525A7E4C500E3A49C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D0F5880425A7E4C500E3A49C /* Kingfisher */; }; D0F5880F25A7E6CC00E3A49C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D0F5880E25A7E6CC00E3A49C /* Kingfisher */; }; D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */; }; @@ -336,6 +337,7 @@ D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = ""; }; D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = ""; }; D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = ""; }; + D0F4362C25C10B9600E4F896 /* AddIdentityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewController.swift; sourceTree = ""; }; D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusViewController.swift; sourceTree = ""; }; D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = ""; }; @@ -564,6 +566,7 @@ D0C7D43024F76169001EBDBB /* View Controllers */ = { isa = PBXGroup; children = ( + D0F4362C25C10B9600E4F896 /* AddIdentityViewController.swift */, D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */, D087671525BAA8C0001FDD43 /* ExploreViewController.swift */, @@ -879,6 +882,7 @@ D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */, + D0F4362D25C10B9600E4F896 /* AddIdentityViewController.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */, D05936FF25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */, diff --git a/View Controllers/AddIdentityViewController.swift b/View Controllers/AddIdentityViewController.swift new file mode 100644 index 0000000..986c064 --- /dev/null +++ b/View Controllers/AddIdentityViewController.swift @@ -0,0 +1,298 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Kingfisher +import Mastodon +import SafariServices +import SwiftUI +import ViewModels + +final class AddIdentityViewController: UIViewController { + private let viewModel: AddIdentityViewModel + private let displayWelcome: Bool + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let promptLabel = UILabel() + private let urlTextField = UITextField() + private let welcomeLabel = UILabel() + private let instanceVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + private let instanceStackView = UIStackView() + private let instanceTitleLabel = UILabel() + private let instanceURLLabel = UILabel() + private let instanceImageView = AnimatedImageView() + private let logInButton = UIButton(type: .system) + private let activityIndicator = UIActivityIndicatorView() + private let joinButton = UIButton(type: .system) + private let browseButton = UIButton(type: .system) + private let whatIsMastodonButton = UIButton(type: .system) + private var cancellables = Set() + + init(viewModel: AddIdentityViewModel, displayWelcome: Bool) { + self.viewModel = viewModel + self.displayWelcome = displayWelcome + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureViews() + setupViewHierarchy() + setupConstraints() + setupViewModelBindings() + initialDisplay() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(true) + + viewModel.refreshFilter() + } +} + +private extension AddIdentityViewController { + static let whatIsMastodonURL = URL(string: "https://joinmastodon.org")! + + // swiftlint:disable:next function_body_length + func configureViews() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = 20 + stackView.axis = .vertical + stackView.distribution = .equalSpacing + + welcomeLabel.numberOfLines = 0 + welcomeLabel.textAlignment = .center + welcomeLabel.adjustsFontForContentSizeCategory = true + welcomeLabel.font = .preferredFont(forTextStyle: .largeTitle) + welcomeLabel.text = NSLocalizedString("add-identity.welcome", comment: "") + + promptLabel.numberOfLines = 0 + promptLabel.textAlignment = .center + promptLabel.adjustsFontForContentSizeCategory = true + promptLabel.font = .preferredFont(forTextStyle: .callout) + promptLabel.text = NSLocalizedString("add-identity.prompt", comment: "") + + urlTextField.borderStyle = .roundedRect + urlTextField.textContentType = .URL + urlTextField.autocapitalizationType = .none + urlTextField.autocorrectionType = .no + urlTextField.keyboardType = .URL + urlTextField.placeholder = NSLocalizedString("add-identity.instance-url", comment: "") + urlTextField.addAction( + UIAction { [weak self] _ in self?.viewModel.urlFieldText = self?.urlTextField.text ?? "" }, + for: .editingChanged) + + logInButton.setTitle(NSLocalizedString("add-identity.log-in", comment: ""), for: .normal) + logInButton.addAction( + UIAction { [weak self] _ in self?.viewModel.logInTapped() }, + for: .touchUpInside) + + activityIndicator.hidesWhenStopped = true + + instanceVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + + instanceStackView.translatesAutoresizingMaskIntoConstraints = false + instanceStackView.axis = .vertical + instanceStackView.spacing = .compactSpacing + + instanceTitleLabel.numberOfLines = 0 + instanceTitleLabel.textAlignment = .center + instanceTitleLabel.adjustsFontForContentSizeCategory = true + instanceTitleLabel.font = .preferredFont(forTextStyle: .headline) + + instanceURLLabel.numberOfLines = 0 + instanceURLLabel.textAlignment = .center + instanceURLLabel.adjustsFontForContentSizeCategory = true + instanceURLLabel.font = .preferredFont(forTextStyle: .subheadline) + instanceURLLabel.textColor = .secondaryLabel + + instanceImageView.contentMode = .scaleAspectFill + instanceImageView.layer.cornerRadius = .defaultCornerRadius + instanceImageView.clipsToBounds = true + instanceImageView.kf.indicatorType = .activity + instanceImageView.isHidden = true + + joinButton.addAction(UIAction { [weak self] _ in self?.join() }, for: .touchUpInside) + + browseButton.setTitle(NSLocalizedString("add-identity.browse", comment: ""), for: .normal) + browseButton.isHidden = true + browseButton.addAction( + UIAction { [weak self] _ in self?.viewModel.browseTapped() }, + for: .touchUpInside) + + whatIsMastodonButton.setTitle(NSLocalizedString("add-identity.what-is-mastodon", comment: ""), for: .normal) + whatIsMastodonButton.addAction( + UIAction { [weak self] _ in + self?.present(SFSafariViewController(url: Self.whatIsMastodonURL), animated: true) + }, + for: .touchUpInside) + + for button in [logInButton, browseButton, joinButton, whatIsMastodonButton] { + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.font = .preferredFont(forTextStyle: .title3) + } + } + + func setupViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(stackView) + stackView.addArrangedSubview(promptLabel) + stackView.addArrangedSubview(urlTextField) + stackView.addArrangedSubview(welcomeLabel) + instanceStackView.addArrangedSubview(instanceTitleLabel) + instanceStackView.addArrangedSubview(instanceURLLabel) + instanceVisualEffectView.contentView.addSubview(instanceStackView) + instanceImageView.addSubview(instanceVisualEffectView) + stackView.addArrangedSubview(instanceImageView) + stackView.addArrangedSubview(activityIndicator) + stackView.addArrangedSubview(logInButton) + stackView.addArrangedSubview(joinButton) + stackView.addArrangedSubview(browseButton) + stackView.addArrangedSubview(whatIsMastodonButton) + } + + func setupConstraints() { + let instanceImageViewWidthConstraint = instanceImageView.widthAnchor.constraint( + equalTo: instanceImageView.heightAnchor, multiplier: 16 / 9) + instanceImageViewWidthConstraint.priority = .justBelowMax + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: .defaultSpacing), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.readableContentGuide.widthAnchor), + stackView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + instanceImageViewWidthConstraint, + instanceVisualEffectView.leadingAnchor.constraint(equalTo: instanceImageView.leadingAnchor), + instanceVisualEffectView.trailingAnchor.constraint(equalTo: instanceImageView.trailingAnchor), + instanceVisualEffectView.bottomAnchor.constraint(equalTo: instanceImageView.bottomAnchor), + instanceStackView.leadingAnchor.constraint(equalTo: instanceVisualEffectView.contentView.leadingAnchor), + instanceStackView.topAnchor.constraint(equalTo: instanceVisualEffectView.contentView.topAnchor, + constant: .defaultSpacing), + instanceStackView.trailingAnchor.constraint(equalTo: instanceVisualEffectView.contentView.trailingAnchor), + instanceStackView.bottomAnchor.constraint(equalTo: instanceVisualEffectView.contentView.bottomAnchor, + constant: -.defaultSpacing) + ]) + } + + func setupViewModelBindings() { + viewModel.$loading.sink { [weak self] in + guard let self = self else { return } + + if $0 { + self.activityIndicator.startAnimating() + self.logInButton.isHidden = true + self.joinButton.isHidden = true + self.browseButton.isHidden = true + self.whatIsMastodonButton.isHidden = true + } else { + self.activityIndicator.stopAnimating() + self.logInButton.isHidden = false + self.joinButton.isHidden = !(self.viewModel.instance?.registrations ?? true) + self.browseButton.isHidden = !self.viewModel.isPublicTimelineAvailable + self.whatIsMastodonButton.isHidden = false + } + } + .store(in: &cancellables) + + viewModel.$instance.combineLatest(viewModel.$isPublicTimelineAvailable) + .sink { [weak self] in self?.configure(instance: $0, isPublicTimelineAvailable: $1) } + .store(in: &cancellables) + + viewModel.$alertItem + .compactMap { $0 } + .sink { [weak self] in self?.present(alertItem: $0) } + .store(in: &cancellables) + } + + func initialDisplay() { + if displayWelcome { + welcomeLabel.alpha = 0 + promptLabel.alpha = 0 + urlTextField.alpha = 0 + logInButton.alpha = 0 + whatIsMastodonButton.alpha = 0 + + UIView.animate(withDuration: .longAnimationDuration * 2) { + self.welcomeLabel.alpha = 1 + } completion: { _ in + UIView.animate(withDuration: .longAnimationDuration * 2) { + self.welcomeLabel.alpha = 0 + } completion: { _ in + self.welcomeLabel.isHidden = true + UIView.animate(withDuration: .longAnimationDuration) { + self.promptLabel.alpha = 1 + } completion: { _ in + UIView.animate(withDuration: .longAnimationDuration) { + self.urlTextField.alpha = 1 + } completion: { _ in + self.urlTextField.becomeFirstResponder() + UIView.animate(withDuration: .longAnimationDuration) { + self.logInButton.alpha = 1 + } completion: { _ in + UIView.animate(withDuration: .longAnimationDuration) { + self.whatIsMastodonButton.alpha = 1 + } + } + } + } + } + } + } else { + welcomeLabel.isHidden = true + whatIsMastodonButton.isHidden = true + urlTextField.becomeFirstResponder() + } + } + + func configure(instance: Instance?, isPublicTimelineAvailable: Bool) { + if let instance = instance { + instanceTitleLabel.text = instance.title + instanceURLLabel.text = instance.uri + instanceImageView.kf.setImage(with: instance.thumbnail) + instanceImageView.isHidden = false + + if instance.registrations { + let joinButtonTitle: String + + if instance.approvalRequired { + joinButtonTitle = NSLocalizedString("add-identity.request-invite", comment: "") + } else { + joinButtonTitle = NSLocalizedString("add-identity.join", comment: "") + } + + joinButton.setTitle(joinButtonTitle, for: .normal) + joinButton.isHidden = false + } else { + joinButton.isHidden = true + } + + browseButton.isHidden = !isPublicTimelineAvailable + } else { + instanceImageView.isHidden = true + joinButton.isHidden = true + browseButton.isHidden = true + } + } + + func join() { + guard let instance = viewModel.instance, let url = viewModel.url else { return } + + let registrationViewModel = viewModel.registrationViewModel(instance: instance, url: url) + let registrationView = RegistrationView(viewModel: registrationViewModel) + let registrationViewController = UIHostingController(rootView: registrationView) + + show(registrationViewController, sender: self) + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/AddIdentityViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AddIdentityViewModel.swift index 939cf08..e653b55 100644 --- a/ViewModels/Sources/ViewModels/View Models/AddIdentityViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/AddIdentityViewModel.swift @@ -27,7 +27,7 @@ public final class AddIdentityViewModel: ObservableObject { self.instanceURLService = instanceURLService let url = $urlFieldText - .debounce(for: .seconds(Self.textFieldDebounceInterval), scheduler: DispatchQueue.global()) + .throttle(for: .seconds(Self.textFieldThrottleInterval), scheduler: DispatchQueue.global(), latest: true) .removeDuplicates() .flatMap { instanceURLService.url(text: $0).publisher @@ -86,7 +86,7 @@ public extension AddIdentityViewModel { } private extension AddIdentityViewModel { - private static let textFieldDebounceInterval: TimeInterval = 0.5 + private static let textFieldThrottleInterval: TimeInterval = 0.5 func addIdentity(kind: AllIdentitiesService.IdentityCreation) { instanceURLService.url(text: urlFieldText).publisher .map { ($0, kind) } diff --git a/Views/AddIdentityView.swift b/Views/AddIdentityView.swift index c79bdae..d8a5d66 100644 --- a/Views/AddIdentityView.swift +++ b/Views/AddIdentityView.swift @@ -4,68 +4,16 @@ import Kingfisher import SwiftUI import ViewModels -struct AddIdentityView: View { - @StateObject var viewModel: AddIdentityViewModel - @Environment(\.accessibilityReduceMotion) var accessibilityReduceMotion - @EnvironmentObject var rootViewModel: RootViewModel +struct AddIdentityView: UIViewControllerRepresentable { + let viewModelClosure: () -> AddIdentityViewModel + let displayWelcome: Bool + + func makeUIViewController(context: Context) -> AddIdentityViewController { + AddIdentityViewController(viewModel: viewModelClosure(), displayWelcome: displayWelcome) + } + + func updateUIViewController(_ uiViewController: AddIdentityViewController, context: Context) { - var body: some View { - Form { - Section { - TextField("add-identity.instance-url", text: $viewModel.urlFieldText) - .textContentType(.URL) - .autocapitalization(.none) - .disableAutocorrection(true) - .keyboardType(.URL) - if let instance = viewModel.instance { - VStack(alignment: .center) { - KFImage(instance.thumbnail) - .placeholder { - ProgressView() - } - .resizable() - .aspectRatio(16 / 9, contentMode: .fill) - .background(Color.blue) - Spacer() - Text(instance.title) - .font(.headline) - Text(instance.uri) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowInsets(EdgeInsets()) - } - Group { - if viewModel.loading { - ProgressView() - } else { - Button("add-identity.log-in", - action: viewModel.logInTapped) - if viewModel.isPublicTimelineAvailable { - Button("add-identity.browse", action: viewModel.browseTapped) - } - if let instance = viewModel.instance, - let url = viewModel.url, - instance.registrations { - NavigationLink( - instance.approvalRequired - ? "add-identity.request-invite" - : "add-identity.join", - destination: RegistrationView( - viewModel: viewModel.registrationViewModel( - instance: instance, - url: url))) - } - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - } - .animation(.default, if: !accessibilityReduceMotion) - .alertItem($viewModel.alertItem) - .onAppear(perform: viewModel.refreshFilter) } } @@ -87,7 +35,7 @@ import PreviewViewModels struct AddAccountView_Previews: PreviewProvider { static var previews: some View { NavigationView { - AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel()) + AddIdentityView(viewModelClosure: { RootViewModel.preview.addIdentityViewModel() }, displayWelcome: false) .navigationBarTitleDisplayMode(.inline) } } diff --git a/Views/IdentitiesView.swift b/Views/IdentitiesView.swift index fd3e968..9d9d045 100644 --- a/Views/IdentitiesView.swift +++ b/Views/IdentitiesView.swift @@ -13,7 +13,9 @@ struct IdentitiesView: View { Form { Section { NavigationLink( - destination: AddIdentityView(viewModel: rootViewModel.addIdentityViewModel()), + destination: AddIdentityView( + viewModelClosure: { rootViewModel.addIdentityViewModel() }, + displayWelcome: false), label: { Label("add", systemImage: "plus.circle") }) diff --git a/Views/RegistrationView.swift b/Views/RegistrationView.swift index cbf3791..215b067 100644 --- a/Views/RegistrationView.swift +++ b/Views/RegistrationView.swift @@ -50,7 +50,7 @@ struct RegistrationView: View { .frame(maxWidth: .infinity, alignment: .center) } .alertItem($viewModel.alertItem) - .sheet(item: $presentURL) { SafariView(url: $0) } + .sheet(item: $presentURL) { SafariView(url: $0).edgesIgnoringSafeArea(.all) } } } diff --git a/Views/RootView.swift b/Views/RootView.swift index 080da15..904ef08 100644 --- a/Views/RootView.swift +++ b/Views/RootView.swift @@ -15,7 +15,9 @@ struct RootView: View { .edgesIgnoringSafeArea(.all) } else { NavigationView { - AddIdentityView(viewModel: viewModel.addIdentityViewModel()) + AddIdentityView( + viewModelClosure: { viewModel.addIdentityViewModel() }, + displayWelcome: true) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) } diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 5169229..8e85b09 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -24,6 +24,7 @@ extension CGRect { extension TimeInterval { static let defaultAnimationDuration: Self = 0.5 static let shortAnimationDuration = defaultAnimationDuration / 2 + static let longAnimationDuration: Self = 1 static func zeroIfReduceMotion(_ duration: Self) -> Self { UIAccessibility.isReduceMotionEnabled ? 0 : duration } }