// // ReportViewController.swift // Mastodon // // Created by ihugo on 2021/4/20. // import AVKit import Combine import CoreData import CoreDataStack import os.log import UIKit import TwitterTextEditor import MastodonSDK class ReportViewController: UIViewController, NeedsDependency { static let kAnimationDuration: TimeInterval = 0.33 weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() let didToggleSelected = PassthroughSubject() let comment = CurrentValueSubject(nil) let step1Continue = PassthroughSubject() let step1Skip = PassthroughSubject() let step2Continue = PassthroughSubject() let step2Skip = PassthroughSubject() let cancel = PassthroughSubject() // MAKK: - UI lazy var header: ReportHeaderView = { let view = ReportHeaderView() view.translatesAutoresizingMaskIntoConstraints = false return view }() lazy var footer: ReportFooterView = { let view = ReportFooterView() view.translatesAutoresizingMaskIntoConstraints = false return view }() lazy var contentView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.setContentHuggingPriority(.defaultLow, for: .vertical) view.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color return view }() lazy var stackview: UIStackView = { let view = UIStackView() view.axis = .vertical view.alignment = .fill view.distribution = .fill view.translatesAutoresizingMaskIntoConstraints = false return view }() lazy var tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(ReportedStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportedStatusTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self tableView.prefetchDataSource = self tableView.allowsMultipleSelection = true return tableView }() lazy var textView: UITextView = { let textView = UITextView() textView.font = .preferredFont(forTextStyle: .body) textView.isScrollEnabled = false textView.placeholder = L10n.Scene.Report.textPlaceholder textView.backgroundColor = .clear textView.delegate = self textView.isScrollEnabled = true textView.keyboardDismissMode = .onDrag return textView }() lazy var bottomSpacing: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false return view }() var bottomConstraint: NSLayoutConstraint! override func viewDidLoad() { super.viewDidLoad() setupView() viewModel.setupDiffableDataSource( for: tableView, dependency: self ) bindViewModel() bindActions() } // MAKR: - Private methods private func setupView() { view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color setupNavigation() stackview.addArrangedSubview(header) stackview.addArrangedSubview(contentView) stackview.addArrangedSubview(footer) stackview.addArrangedSubview(bottomSpacing) contentView.addSubview(tableView) view.addSubview(stackview) NSLayoutConstraint.activate([ stackview.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), stackview.leadingAnchor.constraint(equalTo: view.leadingAnchor), stackview.bottomAnchor.constraint(equalTo: view.bottomAnchor), stackview.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.topAnchor.constraint(equalTo: contentView.topAnchor), tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), ]) self.bottomConstraint = bottomSpacing.heightAnchor.constraint(equalToConstant: 0) bottomConstraint.isActive = true header.step = .one } private func bindActions() { footer.nextStepButton.addTarget(self, action: #selector(continueButtonDidClick), for: .touchUpInside) footer.skipButton.addTarget(self, action: #selector(skipButtonDidClick), for: .touchUpInside) } private func bindViewModel() { let input = ReportViewModel.Input( didToggleSelected: didToggleSelected.eraseToAnyPublisher(), comment: comment.eraseToAnyPublisher(), step1Continue: step1Continue.eraseToAnyPublisher(), step1Skip: step1Skip.eraseToAnyPublisher(), step2Continue: step2Continue.eraseToAnyPublisher(), step2Skip: step2Skip.eraseToAnyPublisher(), cancel: cancel.eraseToAnyPublisher() ) let output = viewModel.transform(input: input) output?.currentStep .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] (step) in guard step == .two else { return } guard let self = self else { return } self.header.step = .two self.footer.step = .two self.switchToStep2Content() }) .store(in: &disposeBag) output?.continueEnableSubject .receive(on: DispatchQueue.main) .filter { [weak self] _ in guard let step = self?.viewModel.currentStep.value, step == .one else { return false } return true } .assign(to: \.nextStepButton.isEnabled, on: footer) .store(in: &disposeBag) output?.sendEnableSubject .receive(on: DispatchQueue.main) .filter { [weak self] _ in guard let step = self?.viewModel.currentStep.value, step == .two else { return false } return true } .assign(to: \.nextStepButton.isEnabled, on: footer) .store(in: &disposeBag) output?.reportResult .print() .receive(on: DispatchQueue.main) .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] data in let (success, error) = data if success { self?.dismiss(animated: true, completion: nil) } else if let error = error { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) let okAction = UIAlertAction(title: "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) Publishers.CombineLatest( KeyboardResponderService.shared.state.eraseToAnyPublisher(), KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() ) .sink(receiveValue: { [weak self] state, endFrame in guard let self = self else { return } guard state == .dock else { self.bottomConstraint.constant = 0.0 return } let contentFrame = self.view.convert(self.view.frame, to: nil) let padding = contentFrame.maxY - endFrame.minY guard padding > 0 else { self.bottomConstraint.constant = 0.0 UIView.animate(withDuration: 0.33) { self.view.layoutIfNeeded() } return } self.bottomConstraint.constant = padding UIView.animate(withDuration: 0.33) { self.view.layoutIfNeeded() } }) .store(in: &disposeBag) } private func setupNavigation() { navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, target: self, action: #selector(doneButtonDidClick)) navigationItem.rightBarButtonItem?.tintColor = Asset.Colors.brandBlue.color // fetch old mastodon user let beReportedUser: MastodonUser? = { guard let domain = context.authenticationService.activeMastodonAuthenticationBox.value?.domain else { return nil } let request = MastodonUser.sortedFetchRequest request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.user.id) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { return try viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext.fetch(request).first } catch { assertionFailure(error.localizedDescription) return nil } }() navigationItem.title = L10n.Scene.Report.title( beReportedUser?.displayNameWithFallback ?? "" ) } private func switchToStep2Content() { self.contentView.addSubview(self.textView) self.textView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ self.textView.topAnchor.constraint(equalTo: self.contentView.topAnchor), self.textView.leadingAnchor.constraint( equalTo: self.contentView.readableContentGuide.leadingAnchor, constant: ReportView.horizontalMargin ), self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), self.contentView.trailingAnchor.constraint( equalTo: self.textView.trailingAnchor, constant: ReportView.horizontalMargin ), ]) self.textView.layoutIfNeeded() UIView.transition( with: contentView, duration: ReportViewController.kAnimationDuration, options: UIView.AnimationOptions.transitionCrossDissolve) { [weak self] in guard let self = self else { return } self.contentView.addSubview(self.textView) self.tableView.isHidden = true } completion: { (_) in } } // Mark: - Actions @objc func doneButtonDidClick() { dismiss(animated: true, completion: nil) } @objc func continueButtonDidClick() { if viewModel.currentStep.value == .one { step1Continue.send() } else { step2Continue.send() } } @objc func skipButtonDidClick() { if viewModel.currentStep.value == .one { step1Skip.send() } else { step2Skip.send() } } } // MARK: - UITableViewDelegate extension ReportViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } didToggleSelected.send(item) } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } didToggleSelected.send(item) } } // MARK: - UITableViewDataSourcePrefetching extension ReportViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { viewModel.prefetchData(prefetchRowsAt: indexPaths) } } // MARK: - UITextViewDelegate extension ReportViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { self.comment.send(textView.text) } }