diff --git a/Mastodon/Scene/Onboarding/Login/MastodonLoginView.swift b/Mastodon/Scene/Onboarding/Login/MastodonLoginView.swift index 4b96679f2..e71890409 100644 --- a/Mastodon/Scene/Onboarding/Login/MastodonLoginView.swift +++ b/Mastodon/Scene/Onboarding/Login/MastodonLoginView.swift @@ -21,8 +21,6 @@ class MastodonLoginView: UIView { private let searchContainerLeftPaddingView: UIView let tableView: UITableView - let navigationActionView: NavigationActionView - var bottomConstraint: NSLayoutConstraint? override init(frame: CGRect) { @@ -64,15 +62,11 @@ class MastodonLoginView: UIView { tableView.keyboardDismissMode = .onDrag tableView.layer.cornerRadius = 10 - navigationActionView = NavigationActionView() - navigationActionView.translatesAutoresizingMaskIntoConstraints = false - super.init(frame: frame) addSubview(explanationTextLabel) addSubview(searchTextField) addSubview(tableView) - addSubview(navigationActionView) backgroundColor = Asset.Scene.Onboarding.background.color setupConstraints() @@ -84,8 +78,6 @@ class MastodonLoginView: UIView { private func setupConstraints() { - let bottomConstraint = safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor) - let constraints = [ explanationTextLabel.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), explanationTextLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), @@ -109,14 +101,9 @@ class MastodonLoginView: UIView { tableView.topAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 2), tableView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), trailingAnchor.constraint(equalTo: tableView.trailingAnchor, constant: 16), - tableView.bottomAnchor.constraint(lessThanOrEqualTo: navigationActionView.topAnchor), - - navigationActionView.leadingAnchor.constraint(equalTo: leadingAnchor), - navigationActionView.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomConstraint, + tableView.bottomAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.bottomAnchor), ] - self.bottomConstraint = bottomConstraint NSLayoutConstraint.activate(constraints) } diff --git a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift index c9609ebc3..01ee5acfd 100644 --- a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift +++ b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift @@ -14,240 +14,264 @@ import AuthenticationServices import MastodonLocalization protocol MastodonLoginViewControllerDelegate: AnyObject { - func backButtonPressed(_ viewController: MastodonLoginViewController) - func nextButtonPressed(_ viewController: MastodonLoginViewController) + func backButtonPressed(_ viewController: MastodonLoginViewController) + func nextButtonPressed(_ viewController: MastodonLoginViewController) } enum MastodonLoginViewSection: Hashable { - case servers + case servers } class MastodonLoginViewController: UIViewController, NeedsDependency { - - weak var delegate: MastodonLoginViewControllerDelegate? - var dataSource: UITableViewDiffableDataSource? - let viewModel: MastodonLoginViewModel - let authenticationViewModel: AuthenticationViewModel - var mastodonAuthenticationController: MastodonAuthenticationController? - - weak var context: AppContext! - weak var coordinator: SceneCoordinator! - - var disposeBag = Set() - - var contentView: MastodonLoginView { - view as! MastodonLoginView - } - - init(appContext: AppContext, authenticationViewModel: AuthenticationViewModel, sceneCoordinator: SceneCoordinator) { - - viewModel = MastodonLoginViewModel(appContext: appContext) - self.authenticationViewModel = authenticationViewModel - self.context = appContext - self.coordinator = sceneCoordinator - - super.init(nibName: nil, bundle: nil) - viewModel.delegate = self - - navigationItem.hidesBackButton = true - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func loadView() { - let loginView = MastodonLoginView() - - loginView.navigationActionView.nextButton.addTarget(self, action: #selector(MastodonLoginViewController.nextButtonPressed(_:)), for: .touchUpInside) - loginView.navigationActionView.backButton.addTarget(self, action: #selector(MastodonLoginViewController.backButtonPressed(_:)), for: .touchUpInside) - loginView.searchTextField.addTarget(self, action: #selector(MastodonLoginViewController.textfieldDidChange(_:)), for: .editingChanged) - loginView.tableView.delegate = self - loginView.tableView.register(MastodonLoginServerTableViewCell.self, forCellReuseIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier) - loginView.navigationActionView.nextButton.isEnabled = false - - view = loginView - } - - override func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShowNotification(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) - - let dataSource = UITableViewDiffableDataSource(tableView: contentView.tableView) { [weak self] tableView, indexPath, itemIdentifier in - guard let cell = tableView.dequeueReusableCell(withIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier, for: indexPath) as? MastodonLoginServerTableViewCell, - let self = self else { - fatalError("Wrong cell") - } - - let server = self.viewModel.filteredServers[indexPath.row] - var configuration = cell.defaultContentConfiguration() - configuration.text = server.domain - - cell.contentConfiguration = configuration - cell.accessoryType = .disclosureIndicator - - cell.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color - - return cell + + enum RightBarButtonState { + case normal, disabled, loading } - - contentView.tableView.dataSource = dataSource - self.dataSource = dataSource - - contentView.updateCorners() - - defer { setupNavigationBarBackgroundView() } - setupOnboardingAppearance() - - title = L10n.Scene.Login.title - navigationItem.hidesBackButton = true - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - viewModel.updateServers() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - contentView.searchTextField.becomeFirstResponder() - } - - //MARK: - Actions - - @objc func backButtonPressed(_ sender: Any) { - contentView.searchTextField.resignFirstResponder() - delegate?.backButtonPressed(self) - } - - @objc func nextButtonPressed(_ sender: Any) { - contentView.searchTextField.resignFirstResponder() - delegate?.nextButtonPressed(self) - } - - @objc func login() { - guard let server = viewModel.selectedServer else { return } - - authenticationViewModel - .authenticated - .asyncMap { domain, user -> Result in - do { - let result = try await self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id) - return .success(result) - } catch { - return .failure(error) - } - } - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isActived): - assert(isActived) - self.coordinator.setup() - } - } - .store(in: &disposeBag) - - authenticationViewModel.isAuthenticating.send(true) - context.apiService.createApplication(domain: server.domain) - .tryMap { response -> AuthenticationViewModel.AuthenticateInfo in - let application = response.value - guard let info = AuthenticationViewModel.AuthenticateInfo( - domain: server.domain, - application: application, - redirectURI: response.value.redirectURI ?? APIService.oauthCallbackURL - ) else { - throw APIService.APIError.explicit(.badResponse) - } - return info - } - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - self.authenticationViewModel.isAuthenticating.send(false) - - switch completion { - case .failure(let error): - let alert = UIAlertController.standardAlert(of: error) - self.present(alert, animated: true) - case .finished: - // do nothing. There's a subscriber above resulting in `coordinator.setup()` - break - } - } receiveValue: { [weak self] info in - guard let self else { return } - let authenticationController = MastodonAuthenticationController( - context: self.context, - authenticateURL: info.authorizeURL + + weak var delegate: MastodonLoginViewControllerDelegate? + var dataSource: UITableViewDiffableDataSource? + let viewModel: MastodonLoginViewModel + let authenticationViewModel: AuthenticationViewModel + var mastodonAuthenticationController: MastodonAuthenticationController? + + weak var context: AppContext! + weak var coordinator: SceneCoordinator! + + var disposeBag = Set() + + var contentView: MastodonLoginView { + view as! MastodonLoginView + } + + init(appContext: AppContext, authenticationViewModel: AuthenticationViewModel, sceneCoordinator: SceneCoordinator) { + + viewModel = MastodonLoginViewModel(appContext: appContext) + self.authenticationViewModel = authenticationViewModel + self.context = appContext + self.coordinator = sceneCoordinator + + super.init(nibName: nil, bundle: nil) + viewModel.delegate = self + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + let loginView = MastodonLoginView() + + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: L10n.Common.Controls.Actions.next, + style: .plain, + target: self, + action: #selector(nextButtonPressed(_:)) ) - - self.mastodonAuthenticationController = authenticationController - authenticationController.authenticationSession?.presentationContextProvider = self - authenticationController.authenticationSession?.start() - - self.authenticationViewModel.authenticate( - info: info, - pinCodePublisher: authenticationController.pinCodePublisher - ) - } - .store(in: &disposeBag) - } - - @objc func textfieldDidChange(_ textField: UITextField) { - viewModel.filterServers(withText: textField.text) - - - if let text = textField.text, - let domain = AuthenticationViewModel.parseDomain(from: text) { - - viewModel.selectedServer = .init(domain: domain, instance: .init(domain: domain)) - contentView.navigationActionView.nextButton.isEnabled = true - } else { - viewModel.selectedServer = nil - contentView.navigationActionView.nextButton.isEnabled = false + + navigationItem.leftBarButtonItem?.target = self + navigationItem.leftBarButtonItem?.action = #selector(backButtonPressed(_:)) + + loginView.searchTextField.addTarget(self, action: #selector(MastodonLoginViewController.textfieldDidChange(_:)), for: .editingChanged) + loginView.tableView.delegate = self + loginView.tableView.register(MastodonLoginServerTableViewCell.self, forCellReuseIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier) + setRightBarButtonState(.disabled) + + view = loginView } - } - - // MARK: - Notifications - @objc func keyboardWillShowNotification(_ notification: Notification) { - - guard let userInfo = notification.userInfo, - let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue, - let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber - else { return } - - // inspired by https://stackoverflow.com/a/30245044 - let keyboardFrame = keyboardFrameValue.cgRectValue - - let keyboardOrigin = view.convert(keyboardFrame.origin, from: nil) - let intersectionY = CGRectGetMaxY(view.frame) - keyboardOrigin.y; - - if intersectionY >= 0 { - contentView.bottomConstraint?.constant = intersectionY - view.safeAreaInsets.bottom + + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShowNotification(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + + let dataSource = UITableViewDiffableDataSource(tableView: contentView.tableView) { [weak self] tableView, indexPath, itemIdentifier in + guard let cell = tableView.dequeueReusableCell(withIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier, for: indexPath) as? MastodonLoginServerTableViewCell, + let self = self else { + fatalError("Wrong cell") + } + + let server = self.viewModel.filteredServers[indexPath.row] + var configuration = cell.defaultContentConfiguration() + configuration.text = server.domain + + cell.contentConfiguration = configuration + cell.accessoryType = .disclosureIndicator + + cell.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color + + return cell + } + + contentView.tableView.dataSource = dataSource + self.dataSource = dataSource + + contentView.updateCorners() + + defer { setupNavigationBarBackgroundView() } + setupOnboardingAppearance() + + title = L10n.Scene.Login.title } - - UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) { - self.view.layoutIfNeeded() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + viewModel.updateServers() } - } - - @objc func keyboardWillHideNotification(_ notification: Notification) { - - guard let userInfo = notification.userInfo, - let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber - else { return } - - contentView.bottomConstraint?.constant = 0 - - UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) { - self.view.layoutIfNeeded() + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + contentView.searchTextField.becomeFirstResponder() + } + + //MARK: - Actions + + @objc func backButtonPressed(_ sender: Any) { + contentView.searchTextField.resignFirstResponder() + delegate?.backButtonPressed(self) + } + + @objc func nextButtonPressed(_ sender: Any) { + contentView.searchTextField.resignFirstResponder() + delegate?.nextButtonPressed(self) + setRightBarButtonState(.loading) + } + + @objc func login() { + guard let server = viewModel.selectedServer else { return } + + authenticationViewModel + .authenticated + .asyncMap { domain, user -> Result in + do { + let result = try await self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id) + return .success(result) + } catch { + return .failure(error) + } + } + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isActived): + assert(isActived) + self.coordinator.setup() + } + } + .store(in: &disposeBag) + + authenticationViewModel.isAuthenticating.send(true) + context.apiService.createApplication(domain: server.domain) + .tryMap { response -> AuthenticationViewModel.AuthenticateInfo in + let application = response.value + guard let info = AuthenticationViewModel.AuthenticateInfo( + domain: server.domain, + application: application, + redirectURI: response.value.redirectURI ?? APIService.oauthCallbackURL + ) else { + throw APIService.APIError.explicit(.badResponse) + } + return info + } + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.authenticationViewModel.isAuthenticating.send(false) + + switch completion { + case .failure(let error): + let alert = UIAlertController.standardAlert(of: error) + self.present(alert, animated: true) + self.setRightBarButtonState(.normal) + case .finished: + self.setRightBarButtonState(.normal) + break + } + } receiveValue: { [weak self] info in + guard let self else { return } + let authenticationController = MastodonAuthenticationController( + context: self.context, + authenticateURL: info.authorizeURL + ) + + self.mastodonAuthenticationController = authenticationController + authenticationController.authenticationSession?.presentationContextProvider = self + authenticationController.authenticationSession?.start() + + self.authenticationViewModel.authenticate( + info: info, + pinCodePublisher: authenticationController.pinCodePublisher + ) + } + .store(in: &disposeBag) + } + + @objc func textfieldDidChange(_ textField: UITextField) { + viewModel.filterServers(withText: textField.text) + + + if let text = textField.text, + let domain = AuthenticationViewModel.parseDomain(from: text) { + + viewModel.selectedServer = .init(domain: domain, instance: .init(domain: domain)) + setRightBarButtonState(.normal) + } else { + viewModel.selectedServer = nil + setRightBarButtonState(.disabled) + } + } + + // MARK: - Notifications + @objc func keyboardWillShowNotification(_ notification: Notification) { + + guard let userInfo = notification.userInfo, + let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue, + let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber + else { return } + + // inspired by https://stackoverflow.com/a/30245044 + let keyboardFrame = keyboardFrameValue.cgRectValue + + let keyboardOrigin = view.convert(keyboardFrame.origin, from: nil) + let intersectionY = CGRectGetMaxY(view.frame) - keyboardOrigin.y; + + UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } + } + + @objc func keyboardWillHideNotification(_ notification: Notification) { + + guard let userInfo = notification.userInfo, + let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber + else { return } + + UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } + } + + private func setRightBarButtonState(_ state: RightBarButtonState) { + switch state { + case .normal: + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: L10n.Common.Controls.Actions.next, + style: .plain, + target: self, + action: #selector(nextButtonPressed(_:)) + ) + case .disabled: + navigationItem.rightBarButtonItem?.isEnabled = false + case .loading: + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + let barButtonItem = UIBarButtonItem(customView: activityIndicator) + navigationItem.rightBarButtonItem = barButtonItem + } } - } } // MARK: - OnboardingViewControllerAppearance @@ -255,38 +279,38 @@ extension MastodonLoginViewController: OnboardingViewControllerAppearance { } // MARK: - UITableViewDelegate extension MastodonLoginViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let server = viewModel.filteredServers[indexPath.row] - viewModel.selectedServer = server - - contentView.searchTextField.text = server.domain - viewModel.filterServers(withText: " ") - - contentView.navigationActionView.nextButton.isEnabled = true - tableView.deselectRow(at: indexPath, animated: true) - } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let server = viewModel.filteredServers[indexPath.row] + viewModel.selectedServer = server + + contentView.searchTextField.text = server.domain + viewModel.filterServers(withText: " ") + + setRightBarButtonState(.normal) + tableView.deselectRow(at: indexPath, animated: true) + } } // MARK: - MastodonLoginViewModelDelegate extension MastodonLoginViewController: MastodonLoginViewModelDelegate { - func serversUpdated(_ viewModel: MastodonLoginViewModel) { - var snapshot = NSDiffableDataSourceSnapshot() - - snapshot.appendSections([MastodonLoginViewSection.servers]) - snapshot.appendItems(viewModel.filteredServers) - - dataSource?.apply(snapshot, animatingDifferences: false) - - OperationQueue.main.addOperation { - let numberOfResults = viewModel.filteredServers.count - self.contentView.updateCorners(numberOfResults: numberOfResults) + func serversUpdated(_ viewModel: MastodonLoginViewModel) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([MastodonLoginViewSection.servers]) + snapshot.appendItems(viewModel.filteredServers) + + dataSource?.apply(snapshot, animatingDifferences: false) + + OperationQueue.main.addOperation { + let numberOfResults = viewModel.filteredServers.count + self.contentView.updateCorners(numberOfResults: numberOfResults) + } } - } } // MARK: - ASWebAuthenticationPresentationContextProviding extension MastodonLoginViewController: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return view.window! - } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return view.window! + } }