mastodon-ios/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewControl...

407 lines
17 KiB
Swift

//
// MastodonRegisterViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-2-5.
//
import AlamofireImage
import Combine
import MastodonSDK
import os.log
import PhotosUI
import UIKit
import MastodonUI
import MastodonAsset
import MastodonLocalization
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400)
let logger = Logger(subsystem: "MastodonRegisterViewController", category: "ViewController")
var disposeBag = Set<AnyCancellable>()
private var observations = Set<NSKeyValueObservation>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: MastodonRegisterViewModel!
// picker
private(set) lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 1
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}()
private(set) lazy var imagePickerController: UIImagePickerController = {
let imagePickerController = UIImagePickerController()
imagePickerController.sourceType = .camera
imagePickerController.delegate = self
return imagePickerController
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
documentPickerController.delegate = self
return documentPickerController
}()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
let tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.keyboardDismissMode = .onDrag
if #available(iOS 15.0, *) {
tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude
} else {
// Fallback on earlier versions
}
return tableView
}()
let navigationActionView: NavigationActionView = {
let navigationActionView = NavigationActionView()
navigationActionView.backgroundColor = Asset.Scene.Onboarding.background.color
return navigationActionView
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
}
}
extension MastodonRegisterViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem()
setupOnboardingAppearance()
defer {
setupNavigationBarBackgroundView()
}
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(navigationActionView)
defer {
view.bringSubviewToFront(navigationActionView)
}
NSLayoutConstraint.activate([
navigationActionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
navigationActionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor),
])
navigationActionView
.observe(\.bounds, options: [.initial, .new]) { [weak self] navigationActionView, _ in
guard let self = self else { return }
let inset = navigationActionView.frame.height
self.tableView.contentInset.bottom = inset
}
.store(in: &observations)
navigationActionView.backButton.addTarget(self, action: #selector(MastodonRegisterViewController.backButtonPressed(_:)), for: .touchUpInside)
navigationActionView.nextButton.addTarget(self, action: #selector(MastodonRegisterViewController.nextButtonPressed(_:)), for: .touchUpInside)
viewModel.$isAllValid
.receive(on: DispatchQueue.main)
.sink { [weak self] isAllValid in
guard let self = self else { return }
self.navigationActionView.nextButton.isEnabled = isAllValid
}
.store(in: &disposeBag)
viewModel.setupDiffableDataSource(tableView: tableView)
KeyboardResponderService
.configure(
scrollView: tableView,
layoutNeedsUpdate: viewModel.viewDidAppear.eraseToAnyPublisher()
)
.store(in: &disposeBag)
// gesture
view.addGestureRecognizer(tapGestureRecognizer)
tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler))
// // return
// if viewModel.approvalRequired {
// reasonTextField.returnKeyType = .done
// } else {
// passwordTextField.returnKeyType = .done
// }
//
// viewModel.usernameValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.usernameErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.usernameErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.displayNameValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.emailValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.emailErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.emailErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.passwordValidateState
// .receive(on: DispatchQueue.main)
// .sink { [weak self] validateState in
// guard let self = self else { return }
// self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState)
// self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState)
// }
// .store(in: &disposeBag)
// viewModel.passwordErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.passwordErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.reasonErrorPrompt
// .receive(on: DispatchQueue.main)
// .sink { [weak self] prompt in
// guard let self = self else { return }
// self.reasonErrorPromptLabel.attributedText = prompt
// }
// .store(in: &disposeBag)
// viewModel.error
// .receive(on: DispatchQueue.main)
// .sink { [weak self] error in
// guard let self = self else { return }
// guard let error = error as? Mastodon.API.Error else { return }
// let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
// alertController.addAction(okAction)
// self.coordinator.present(
// scene: .alertController(alertController: alertController),
// from: nil,
// transition: .alertController(animated: true, completion: nil)
// )
// }
// .store(in: &disposeBag)
//
viewModel.avatarMediaMenuActionPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self = self else { return }
switch action {
case .photoLibrary:
self.present(self.imagePicker, animated: true, completion: nil)
case .camera:
self.present(self.imagePickerController, animated: true, completion: nil)
case .browse:
self.present(self.documentPickerController, animated: true, completion: nil)
case .delete:
self.viewModel.avatarImage = nil
}
}
.store(in: &disposeBag)
viewModel.$isRegistering
.receive(on: DispatchQueue.main)
.sink { [weak self] isRegistering in
guard let self = self else { return }
isRegistering ? self.navigationActionView.nextButton.showLoading() : self.navigationActionView.nextButton.stopLoading()
}
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.send()
}
}
extension MastodonRegisterViewController {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
view.endEditing(true)
}
@objc private func backButtonPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
navigationController?.popViewController(animated: true)
}
@objc private func nextButtonPressed(_ sender: UIButton) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard viewModel.isAllValid else { return }
guard !viewModel.isRegistering else { return }
viewModel.isRegistering = true
let username = viewModel.username
let email = viewModel.email
let password = viewModel.password
let reason = viewModel.reason
let locale: String = {
guard let url = Bundle.main.url(forResource: "local-codes", withExtension: "json"),
let data = try? Data(contentsOf: url),
let localCode = try? JSONDecoder().decode(MastodonLocalCode.self, from: data)
else {
assertionFailure()
return "en"
}
let fallbackLanguageCode: String = {
let code = Locale.current.languageCode ?? "en"
guard localCode[code] != nil else { return "en" }
return code
}()
// pick device preferred language
guard let identifier = Locale.preferredLanguages.first else {
return fallbackLanguageCode
}
// prepare languageCode and validate then return fallback if needs
let local = Locale(identifier: identifier)
guard let languageCode = local.languageCode,
localCode[languageCode] != nil
else {
return fallbackLanguageCode
}
// prepare extendCode and validate then return fallback if needs
let extendCodes: [String] = {
let locales = Locale.preferredLanguages.map { Locale(identifier: $0) }
return locales.compactMap { locale in
guard let languageCode = locale.languageCode,
let regionCode = locale.regionCode
else { return nil }
return languageCode + "-" + regionCode
}
}()
let _firstMatchExtendCode = extendCodes.first { code in
localCode[code] != nil
}
guard let firstMatchExtendCode = _firstMatchExtendCode else {
return languageCode
}
return firstMatchExtendCode
}()
let query = Mastodon.API.Account.RegisterQuery(
reason: reason,
username: username,
email: email,
password: password,
agreement: true, // user confirmed in the server rules scene
locale: locale
)
var retryCount = 0
// register without show server rules
context.apiService.accountRegister(
domain: viewModel.domain,
query: query,
authorization: viewModel.applicationAuthorization
)
.tryCatch { [weak self] error -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> in
guard let self = self else { throw error }
guard let error = self.viewModel.error as? Mastodon.API.Error,
case let .generic(errorEntity) = error.mastodonError,
errorEntity.error == "Validation failed: Locale is not included in the list"
else {
throw error
}
guard retryCount == 0 else {
throw error
}
let retryQuery = Mastodon.API.Account.RegisterQuery(
reason: query.reason,
username: query.username,
email: query.email,
password: query.password,
agreement: query.agreement,
locale: self.viewModel.instance.languages?.first ?? "en"
)
retryCount += 1
return self.context.apiService.accountRegister(
domain: self.viewModel.domain,
query: retryQuery,
authorization: self.viewModel.applicationAuthorization
)
}
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.viewModel.isRegistering = false
switch completion {
case .failure(let error):
self.viewModel.error = error
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let userToken = response.value
let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = {
let displayName: String? = self.viewModel.name.isEmpty ? nil : self.viewModel.name
let avatar: Mastodon.Query.MediaAttachment? = {
guard let avatarImage = self.viewModel.avatarImage else { return nil }
guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData())
}
return .png(avatarImage.pngData())
}()
return Mastodon.API.Account.UpdateCredentialQuery(
displayName: displayName,
avatar: avatar
)
}()
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery)
self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show)
}
.store(in: &disposeBag)
}
}