Merge pull request #323 from mastodon/feature/v2-onboarding
Implement v2 Onboarding scene
|
@ -7,7 +7,6 @@ set -eo pipefail
|
|||
|
||||
xcodebuild -workspace Mastodon.xcworkspace \
|
||||
-scheme Mastodon \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
|
||||
clean \
|
||||
build | xcpretty
|
||||
build | xcpretty
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>1.3.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>88</string>
|
||||
<string>90</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>1.3.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>88</string>
|
||||
<string>90</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>1.3.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>88</string>
|
||||
<string>90</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -193,10 +193,14 @@
|
|||
},
|
||||
"scene": {
|
||||
"welcome": {
|
||||
"slogan": "Social networking\nback in your hands."
|
||||
"slogan": "Social networking\nback in your hands.",
|
||||
"get_started": "Get Started",
|
||||
"log_in": "Log In"
|
||||
},
|
||||
"server_picker": {
|
||||
"title": "Pick a server,\nany server.",
|
||||
"title": "Mastodon is made of users in different communities.",
|
||||
"subtitle": "Pick a community based on your interests, region, or a general purpose one.",
|
||||
"subtitle_extend": "Pick a community based on your interests, region, or a general purpose one. Each community is operated by an entirely independent organization or individual.",
|
||||
"button": {
|
||||
"category": {
|
||||
"all": "All",
|
||||
|
@ -223,7 +227,7 @@
|
|||
"category": "CATEGORY"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Find a server or join your own..."
|
||||
"placeholder": "Search communities"
|
||||
},
|
||||
"empty_state": {
|
||||
"finding_servers": "Finding available servers...",
|
||||
|
@ -232,7 +236,7 @@
|
|||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "Tell us about you.",
|
||||
"title": "Let’s get you set up on %s",
|
||||
"input": {
|
||||
"avatar": {
|
||||
"delete": "Delete"
|
||||
|
@ -249,6 +253,12 @@
|
|||
},
|
||||
"password": {
|
||||
"placeholder": "password",
|
||||
"require": "Your password needs at least:",
|
||||
"character_limit": "8 characters",
|
||||
"accessibility": {
|
||||
"checked": "checked",
|
||||
"unchecked": "unchecked"
|
||||
},
|
||||
"hint": "Your password needs at least eight characters"
|
||||
},
|
||||
"invite": {
|
||||
|
@ -286,7 +296,7 @@
|
|||
},
|
||||
"server_rules": {
|
||||
"title": "Some ground rules.",
|
||||
"subtitle": "These rules are set by the admins of %s.",
|
||||
"subtitle": "These are set and enforced by the %s moderators.",
|
||||
"prompt": "By continuing, you’re subject to the terms of service and privacy policy for %s.",
|
||||
"terms_of_service": "terms of service",
|
||||
"privacy_policy": "privacy policy",
|
||||
|
@ -296,10 +306,10 @@
|
|||
},
|
||||
"confirm_email": {
|
||||
"title": "One last thing.",
|
||||
"subtitle": "We just sent an email to %s,\ntap the link to confirm your account.",
|
||||
"subtitle": "Tap the link we emailed to you to verify your account.",
|
||||
"button": {
|
||||
"open_email_app": "Open Email App",
|
||||
"dont_receive_email": "I never got an email"
|
||||
"resend": "Resend"
|
||||
},
|
||||
"dont_receive_email": {
|
||||
"title": "Check your email",
|
||||
|
@ -554,4 +564,4 @@
|
|||
"accessibility_hint": "Double tap to dismiss this wizard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,17 +7,17 @@
|
|||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>44</integer>
|
||||
<integer>26</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>45</integer>
|
||||
<integer>27</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -27,7 +27,7 @@
|
|||
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -102,7 +102,7 @@
|
|||
<key>MastodonIntent.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>43</integer>
|
||||
<integer>25</integer>
|
||||
</dict>
|
||||
<key>MastodonIntents.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -117,12 +117,12 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>7</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>42</integer>
|
||||
<integer>24</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc",
|
||||
"version": "5.4.4"
|
||||
"revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864",
|
||||
"version": "5.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -141,8 +141,8 @@
|
|||
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "a72df4849408da7e5d3c1b586797b7c601c41d1b",
|
||||
"version": "5.12.1"
|
||||
"revision": "0fff0d7505b5306348263ea64fcc561253bbeb21",
|
||||
"version": "5.12.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -157,11 +157,6 @@ extension SceneCoordinator {
|
|||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
||||
case mastodonWebView(viewModel:WebViewModel)
|
||||
|
||||
#if ASDK
|
||||
// ASDK
|
||||
case asyncHome
|
||||
#endif
|
||||
|
||||
// search
|
||||
case searchDetail(viewModel: SearchDetailViewModel)
|
||||
|
@ -260,7 +255,7 @@ extension SceneCoordinator {
|
|||
DispatchQueue.main.async {
|
||||
self.present(
|
||||
scene: .welcome,
|
||||
from: nil,
|
||||
from: self.sceneDelegate.window?.rootViewController,
|
||||
transition: .modal(animated: animated, completion: nil)
|
||||
)
|
||||
}
|
||||
|
@ -311,7 +306,7 @@ extension SceneCoordinator {
|
|||
case .modal(let animated, let completion):
|
||||
let modalNavigationController: UINavigationController = {
|
||||
if scene.isOnboarding {
|
||||
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
|
||||
return OnboardingNavigationController(rootViewController: viewController)
|
||||
} else {
|
||||
return UINavigationController(rootViewController: viewController)
|
||||
}
|
||||
|
@ -412,11 +407,6 @@ private extension SceneCoordinator {
|
|||
let _viewController = WebViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
#if ASDK
|
||||
case .asyncHome:
|
||||
let _viewController = AsyncHomeTimelineViewController()
|
||||
viewController = _viewController
|
||||
#endif
|
||||
case .searchDetail(let viewModel):
|
||||
let _viewController = SearchDetailViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
//
|
||||
// PickServerCategoriesCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by BradGao on 2021/2/23.
|
||||
//
|
||||
|
||||
//import os.log
|
||||
//import UIKit
|
||||
//import MastodonSDK
|
||||
//
|
||||
//protocol PickServerCategoriesCellDelegate: AnyObject {
|
||||
// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
//}
|
||||
//
|
||||
//final class PickServerCategoriesCell: UITableViewCell {
|
||||
//
|
||||
// weak var delegate: PickServerCategoriesCellDelegate?
|
||||
//
|
||||
// var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
|
||||
//
|
||||
// let metricView = UIView()
|
||||
//
|
||||
// let collectionView: UICollectionView = {
|
||||
// let flowLayout = UICollectionViewFlowLayout()
|
||||
// flowLayout.scrollDirection = .horizontal
|
||||
// let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
// view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
|
||||
// view.backgroundColor = .clear
|
||||
// view.showsHorizontalScrollIndicator = false
|
||||
// view.showsVerticalScrollIndicator = false
|
||||
// view.layer.masksToBounds = false
|
||||
// view.translatesAutoresizingMaskIntoConstraints = false
|
||||
// return view
|
||||
// }()
|
||||
//
|
||||
// override func prepareForReuse() {
|
||||
// super.prepareForReuse()
|
||||
//
|
||||
// delegate = nil
|
||||
// }
|
||||
//
|
||||
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
// super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
// _init()
|
||||
// }
|
||||
//
|
||||
// required init?(coder: NSCoder) {
|
||||
// super.init(coder: coder)
|
||||
// _init()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension PickServerCategoriesCell {
|
||||
//
|
||||
// private func _init() {
|
||||
// selectionStyle = .none
|
||||
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
// configureMargin()
|
||||
//
|
||||
// metricView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// contentView.addSubview(metricView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
// metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
// metricView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
// metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
// metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
|
||||
// ])
|
||||
//
|
||||
// contentView.addSubview(collectionView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
// collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
// collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
// contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20),
|
||||
// collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
|
||||
// ])
|
||||
//
|
||||
// collectionView.delegate = self
|
||||
// }
|
||||
//
|
||||
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
// super.traitCollectionDidChange(previousTraitCollection)
|
||||
//
|
||||
// configureMargin()
|
||||
// }
|
||||
//
|
||||
// override func layoutSubviews() {
|
||||
// super.layoutSubviews()
|
||||
//
|
||||
// collectionView.collectionViewLayout.invalidateLayout()
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//
|
||||
//extension PickServerCategoriesCell {
|
||||
// private func configureMargin() {
|
||||
// switch traitCollection.horizontalSizeClass {
|
||||
// case .regular:
|
||||
// let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
// default:
|
||||
// contentView.layoutMargins = .zero
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - UICollectionViewDelegateFlowLayout
|
||||
//extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
|
||||
//
|
||||
// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
|
||||
// collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
|
||||
// delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
|
||||
// }
|
||||
//
|
||||
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
// layoutIfNeeded()
|
||||
// return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
|
||||
// }
|
||||
//
|
||||
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||
// return 16
|
||||
// }
|
||||
//
|
||||
// func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
// return CGSize(width: 60, height: 80)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
//
|
||||
//extension PickServerCategoriesCell {
|
||||
//
|
||||
// override func accessibilityElementCount() -> Int {
|
||||
// guard let diffableDataSource = diffableDataSource else { return 0 }
|
||||
// return diffableDataSource.snapshot().itemIdentifiers.count
|
||||
// }
|
||||
//
|
||||
// override func accessibilityElement(at index: Int) -> Any? {
|
||||
// guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
|
||||
// return item
|
||||
// }
|
||||
//
|
||||
//}
|
|
@ -0,0 +1,171 @@
|
|||
//
|
||||
// PickServerSearchCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by BradGao on 2021/2/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
//protocol PickServerSearchCellDelegate: AnyObject {
|
||||
// func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?)
|
||||
//}
|
||||
//
|
||||
//class PickServerSearchCell: UITableViewCell {
|
||||
//
|
||||
// weak var delegate: PickServerSearchCellDelegate?
|
||||
//
|
||||
// private var bgView: UIView = {
|
||||
// let view = UIView()
|
||||
// view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
|
||||
// view.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.layer.maskedCorners = [
|
||||
// .layerMinXMinYCorner,
|
||||
// .layerMaxXMinYCorner
|
||||
// ]
|
||||
// view.layer.cornerCurve = .continuous
|
||||
// view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
// return view
|
||||
// }()
|
||||
//
|
||||
// private var textFieldBgView: UIView = {
|
||||
// let view = UIView()
|
||||
// view.backgroundColor = Asset.Colors.TextField.background.color
|
||||
// view.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.layer.masksToBounds = true
|
||||
// view.layer.cornerRadius = 6
|
||||
// view.layer.cornerCurve = .continuous
|
||||
// return view
|
||||
// }()
|
||||
//
|
||||
// let searchTextField: UITextField = {
|
||||
// let textField = UITextField()
|
||||
// textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
// textField.leftView = {
|
||||
// let imageView = UIImageView(
|
||||
// image: UIImage(
|
||||
// systemName: "magnifyingglass",
|
||||
// withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular)
|
||||
// )
|
||||
// )
|
||||
// imageView.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)
|
||||
//
|
||||
// let containerView = UIView()
|
||||
// imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// containerView.addSubview(imageView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// imageView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
// imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
// imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
// ])
|
||||
//
|
||||
// let paddingView = UIView()
|
||||
// paddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// containerView.addSubview(paddingView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// paddingView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
// paddingView.leadingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||
// paddingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
// paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
// paddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
|
||||
// ])
|
||||
// return containerView
|
||||
// }()
|
||||
// textField.leftViewMode = .always
|
||||
// textField.font = .systemFont(ofSize: 15, weight: .regular)
|
||||
// textField.tintColor = Asset.Colors.Label.primary.color
|
||||
// textField.textColor = Asset.Colors.Label.primary.color
|
||||
// textField.adjustsFontForContentSizeCategory = true
|
||||
// textField.attributedPlaceholder =
|
||||
// NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
|
||||
// attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular),
|
||||
// .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)])
|
||||
// textField.clearButtonMode = .whileEditing
|
||||
// textField.autocapitalizationType = .none
|
||||
// textField.autocorrectionType = .no
|
||||
// textField.returnKeyType = .done
|
||||
// textField.keyboardType = .URL
|
||||
// return textField
|
||||
// }()
|
||||
//
|
||||
// override func prepareForReuse() {
|
||||
// super.prepareForReuse()
|
||||
//
|
||||
// delegate = nil
|
||||
// }
|
||||
//
|
||||
// override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
// super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
// _init()
|
||||
// }
|
||||
//
|
||||
// required init?(coder: NSCoder) {
|
||||
// super.init(coder: coder)
|
||||
// _init()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension PickServerSearchCell {
|
||||
// private func _init() {
|
||||
// selectionStyle = .none
|
||||
// backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
// configureMargin()
|
||||
//
|
||||
// searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
||||
// searchTextField.delegate = self
|
||||
//
|
||||
// contentView.addSubview(bgView)
|
||||
// contentView.addSubview(textFieldBgView)
|
||||
// contentView.addSubview(searchTextField)
|
||||
//
|
||||
// NSLayoutConstraint.activate([
|
||||
// bgView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
// bgView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
// bgView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
// bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
//
|
||||
// textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14),
|
||||
// textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12),
|
||||
// bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14),
|
||||
// bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13),
|
||||
//
|
||||
// searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11),
|
||||
// searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4),
|
||||
// textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11),
|
||||
// textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4),
|
||||
// ])
|
||||
// }
|
||||
//
|
||||
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
// super.traitCollectionDidChange(previousTraitCollection)
|
||||
//
|
||||
// configureMargin()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension PickServerSearchCell {
|
||||
// private func configureMargin() {
|
||||
// switch traitCollection.horizontalSizeClass {
|
||||
// case .regular:
|
||||
// let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
// contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
// default:
|
||||
// contentView.layoutMargins = .zero
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension PickServerSearchCell {
|
||||
// @objc private func textFieldDidChange(_ textField: UITextField) {
|
||||
// delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - UITextFieldDelegate
|
||||
//extension PickServerSearchCell: UITextFieldDelegate {
|
||||
//
|
||||
// func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
// textField.resignFirstResponder()
|
||||
// return false
|
||||
// }
|
||||
//}
|
|
@ -1,85 +0,0 @@
|
|||
//
|
||||
// ASTableNode.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-6-19.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import DifferenceKit
|
||||
import DiffableDataSources
|
||||
|
||||
extension ASTableNode: ReloadableTableView {
|
||||
public func reload<C>(
|
||||
using stagedChangeset: StagedChangeset<C>,
|
||||
deleteSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
insertSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
reloadSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
deleteRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
insertRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
reloadRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
|
||||
interrupt: ((Changeset<C>) -> Bool)? = nil,
|
||||
setData: (C) -> Void
|
||||
) {
|
||||
if case .none = view.window, let data = stagedChangeset.last?.data {
|
||||
setData(data)
|
||||
return reloadData()
|
||||
}
|
||||
|
||||
for changeset in stagedChangeset {
|
||||
if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data {
|
||||
setData(data)
|
||||
return reloadData()
|
||||
}
|
||||
|
||||
func updates() {
|
||||
setData(changeset.data)
|
||||
|
||||
if !changeset.sectionDeleted.isEmpty {
|
||||
deleteSections(IndexSet(changeset.sectionDeleted), with: deleteSectionsAnimation())
|
||||
}
|
||||
|
||||
if !changeset.sectionInserted.isEmpty {
|
||||
insertSections(IndexSet(changeset.sectionInserted), with: insertSectionsAnimation())
|
||||
}
|
||||
|
||||
if !changeset.sectionUpdated.isEmpty {
|
||||
reloadSections(IndexSet(changeset.sectionUpdated), with: reloadSectionsAnimation())
|
||||
}
|
||||
|
||||
for (source, target) in changeset.sectionMoved {
|
||||
moveSection(source, toSection: target)
|
||||
}
|
||||
|
||||
if !changeset.elementDeleted.isEmpty {
|
||||
deleteRows(at: changeset.elementDeleted.map { IndexPath(row: $0.element, section: $0.section) }, with: deleteRowsAnimation())
|
||||
}
|
||||
|
||||
if !changeset.elementInserted.isEmpty {
|
||||
insertRows(at: changeset.elementInserted.map { IndexPath(row: $0.element, section: $0.section) }, with: insertRowsAnimation())
|
||||
}
|
||||
|
||||
if !changeset.elementUpdated.isEmpty {
|
||||
reloadRows(at: changeset.elementUpdated.map { IndexPath(row: $0.element, section: $0.section) }, with: reloadRowsAnimation())
|
||||
}
|
||||
|
||||
for (source, target) in changeset.elementMoved {
|
||||
moveRow(at: IndexPath(row: source.element, section: source.section), to: IndexPath(row: target.element, section: target.section))
|
||||
}
|
||||
}
|
||||
|
||||
if isNodeLoaded {
|
||||
view.beginUpdates()
|
||||
updates()
|
||||
view.endUpdates(animated: false, completion: nil)
|
||||
} else {
|
||||
updates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,115 +0,0 @@
|
|||
//
|
||||
// TableNodeDiffableDataSource.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-6-19.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import DiffableDataSources
|
||||
|
||||
open class TableNodeDiffableDataSource<SectionIdentifierType: Hashable, ItemIdentifierType: Hashable>: NSObject, ASTableDataSource {
|
||||
/// The type of closure providing the cell.
|
||||
public typealias CellProvider = (ASTableNode, IndexPath, ItemIdentifierType) -> ASCellNodeBlock?
|
||||
|
||||
/// The default animation to updating the views.
|
||||
public var defaultRowAnimation: UITableView.RowAnimation = .automatic
|
||||
|
||||
private weak var tableNode: ASTableNode?
|
||||
private let cellProvider: CellProvider
|
||||
private let core = DiffableDataSourceCore<SectionIdentifierType, ItemIdentifierType>()
|
||||
|
||||
/// Creates a new data source.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - tableView: A table view instance to be managed.
|
||||
/// - cellProvider: A closure to dequeue the cell for rows.
|
||||
public init(tableNode: ASTableNode, cellProvider: @escaping CellProvider) {
|
||||
self.tableNode = tableNode
|
||||
self.cellProvider = cellProvider
|
||||
super.init()
|
||||
|
||||
tableNode.delegate = self
|
||||
}
|
||||
|
||||
/// Applies given snapshot to perform automatic diffing update.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - snapshot: A snapshot object to be applied to data model.
|
||||
/// - animatingDifferences: A Boolean value indicating whether to update with
|
||||
/// diffing animation.
|
||||
/// - completion: An optional completion block which is called when the complete
|
||||
/// performing updates.
|
||||
public func apply(_ snapshot: DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
|
||||
core.apply(snapshot, view: tableNode, animatingDifferences: animatingDifferences, completion: completion)
|
||||
}
|
||||
|
||||
/// Returns a new snapshot object of current state.
|
||||
///
|
||||
/// - Returns: A new snapshot object of current state.
|
||||
public func snapshot() -> DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> {
|
||||
return core.snapshot()
|
||||
}
|
||||
|
||||
/// Returns an item identifier for given index path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - indexPath: An index path for the item identifier.
|
||||
///
|
||||
/// - Returns: An item identifier for given index path.
|
||||
public func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? {
|
||||
return core.itemIdentifier(for: indexPath)
|
||||
}
|
||||
|
||||
/// Returns an index path for given item identifier.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - itemIdentifier: An identifier of item.
|
||||
///
|
||||
/// - Returns: An index path for given item identifier.
|
||||
public func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath? {
|
||||
return core.indexPath(for: itemIdentifier)
|
||||
}
|
||||
|
||||
/// Returns the number of sections in the data source.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - tableNode: A table node instance managed by `self`.
|
||||
///
|
||||
/// - Returns: The number of sections in the data source.
|
||||
public func numberOfSections(in tableNode: ASTableNode) -> Int {
|
||||
return core.numberOfSections()
|
||||
}
|
||||
|
||||
/// Returns the number of items in the specified section.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - tableNode: A table node instance managed by `self`.
|
||||
/// - section: An index of section.
|
||||
///
|
||||
/// - Returns: The number of items in the specified section.
|
||||
public func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
|
||||
return core.numberOfItems(inSection: section)
|
||||
}
|
||||
|
||||
/// Returns a cell for row at specified index path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - tableView: A table view instance managed by `self`.
|
||||
/// - indexPath: An index path for cell.
|
||||
///
|
||||
/// - Returns: A cell for row at specified index path.
|
||||
open func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
|
||||
let itemIdentifier = core.unsafeItemIdentifier(for: indexPath)
|
||||
guard let block = cellProvider(tableNode, indexPath, itemIdentifier) else {
|
||||
fatalError("UITableView dataSource returned a nil cell for row at index path: \(indexPath), tableNode: \(tableNode), itemIdentifier: \(itemIdentifier)")
|
||||
}
|
||||
|
||||
return block
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -15,10 +15,11 @@ enum CategoryPickerItem {
|
|||
}
|
||||
|
||||
extension CategoryPickerItem {
|
||||
var title: String {
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.all
|
||||
return "💬"
|
||||
case .category(let category):
|
||||
switch category.category {
|
||||
case .academia:
|
||||
|
@ -32,7 +33,7 @@ extension CategoryPickerItem {
|
|||
case .games:
|
||||
return "🕹"
|
||||
case .general:
|
||||
return "💬"
|
||||
return "🐘"
|
||||
case .journalism:
|
||||
return "📰"
|
||||
case .lgbt:
|
||||
|
@ -50,6 +51,41 @@ extension CategoryPickerItem {
|
|||
}
|
||||
}
|
||||
}
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.all
|
||||
case .category(let category):
|
||||
switch category.category {
|
||||
case .academia:
|
||||
return L10n.Scene.ServerPicker.Button.Category.academia
|
||||
case .activism:
|
||||
return L10n.Scene.ServerPicker.Button.Category.activism
|
||||
case .food:
|
||||
return L10n.Scene.ServerPicker.Button.Category.food
|
||||
case .furry:
|
||||
return L10n.Scene.ServerPicker.Button.Category.furry
|
||||
case .games:
|
||||
return L10n.Scene.ServerPicker.Button.Category.games
|
||||
case .general:
|
||||
return L10n.Scene.ServerPicker.Button.Category.general
|
||||
case .journalism:
|
||||
return L10n.Scene.ServerPicker.Button.Category.journalism
|
||||
case .lgbt:
|
||||
return L10n.Scene.ServerPicker.Button.Category.lgbt
|
||||
case .regional:
|
||||
return L10n.Scene.ServerPicker.Button.Category.regional
|
||||
case .art:
|
||||
return L10n.Scene.ServerPicker.Button.Category.art
|
||||
case .music:
|
||||
return L10n.Scene.ServerPicker.Button.Category.music
|
||||
case .tech:
|
||||
return L10n.Scene.ServerPicker.Button.Category.tech
|
||||
case ._other:
|
||||
return "-" // FIXME:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
|
@ -82,7 +118,7 @@ extension CategoryPickerItem {
|
|||
case .tech:
|
||||
return L10n.Scene.ServerPicker.Button.Category.tech
|
||||
case ._other:
|
||||
return "❓" // FIXME:
|
||||
return "-" // FIXME:
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,27 +19,11 @@ extension CategoryPickerSection {
|
|||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
guard let _ = dependency else { return nil }
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
|
||||
switch item {
|
||||
case .all:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 17)
|
||||
case .category:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 28)
|
||||
}
|
||||
cell.categoryView.emojiLabel.text = item.emoji
|
||||
cell.categoryView.titleLabel.text = item.title
|
||||
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
|
||||
if cell.isSelected {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = .white
|
||||
}
|
||||
} else {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
|
||||
}
|
||||
}
|
||||
cell.categoryView.highlightedIndicatorView.alpha = cell.isSelected ? 1 : 0
|
||||
cell.categoryView.titleLabel.textColor = cell.isSelected ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color
|
||||
}
|
||||
.store(in: &cell.observations)
|
||||
|
|
@ -12,8 +12,6 @@ import MastodonSDK
|
|||
/// Note: update Equatable when change case
|
||||
enum PickServerItem {
|
||||
case header
|
||||
case categoryPicker(items: [CategoryPickerItem])
|
||||
case search
|
||||
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
|
||||
case loader(attribute: LoaderItemAttribute)
|
||||
}
|
||||
|
@ -63,10 +61,6 @@ extension PickServerItem: Equatable {
|
|||
switch (lhs, rhs) {
|
||||
case (.header, .header):
|
||||
return true
|
||||
case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)):
|
||||
return itemsLeft == itemsRight
|
||||
case (.search, .search):
|
||||
return true
|
||||
case (.server(let serverLeft, _), .server(let serverRight, _)):
|
||||
return serverLeft.domain == serverRight.domain
|
||||
case (.loader(let attributeLeft), loader(let attributeRight)):
|
||||
|
@ -82,10 +76,6 @@ extension PickServerItem: Hashable {
|
|||
switch self {
|
||||
case .header:
|
||||
hasher.combine(String(describing: PickServerItem.header.self))
|
||||
case .categoryPicker(let items):
|
||||
hasher.combine(items)
|
||||
case .search:
|
||||
hasher.combine(String(describing: PickServerItem.search.self))
|
||||
case .server(let server, _):
|
||||
hasher.combine(server.domain)
|
||||
case .loader(let attribute):
|
|
@ -12,8 +12,6 @@ import AlamofireImage
|
|||
|
||||
enum PickServerSection: Equatable, Hashable {
|
||||
case header
|
||||
case category
|
||||
case search
|
||||
case servers
|
||||
}
|
||||
|
||||
|
@ -21,36 +19,16 @@ extension PickServerSection {
|
|||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [
|
||||
weak dependency,
|
||||
weak pickServerCategoriesCellDelegate,
|
||||
weak pickServerSearchCellDelegate,
|
||||
weak pickServerCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return nil }
|
||||
switch item {
|
||||
case .header:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
|
||||
return cell
|
||||
case .categoryPicker(let items):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
|
||||
cell.delegate = pickServerCategoriesCellDelegate
|
||||
cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
|
||||
for: cell.collectionView,
|
||||
dependency: dependency
|
||||
)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
return cell
|
||||
case .search:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
|
||||
cell.delegate = pickServerSearchCellDelegate
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
|
||||
return cell
|
||||
case .server(let server, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
|
||||
|
@ -70,19 +48,63 @@ extension PickServerSection {
|
|||
|
||||
static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) {
|
||||
cell.domainLabel.text = server.domain
|
||||
cell.descriptionLabel.text = {
|
||||
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
|
||||
return server.description
|
||||
}
|
||||
cell.descriptionLabel.attributedText = {
|
||||
let content: String = {
|
||||
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
|
||||
return server.description
|
||||
}
|
||||
return html.text ?? server.description
|
||||
}()
|
||||
|
||||
return html.text ?? server.description
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineHeightMultiple = 1.16
|
||||
|
||||
return NSAttributedString(
|
||||
string: content,
|
||||
attributes: [
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
)
|
||||
}()
|
||||
cell.langValueLabel.text = server.language.uppercased()
|
||||
cell.usersValueLabel.text = parseUsersCount(server.totalUsers)
|
||||
cell.categoryValueLabel.text = server.category.uppercased()
|
||||
|
||||
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
|
||||
|
||||
cell.usersValueLabel.attributedText = {
|
||||
let attributedString = NSMutableAttributedString()
|
||||
let attachment = NSTextAttachment(image: UIImage(systemName: "person.2.fill")!)
|
||||
let attachmentAttributedString = NSAttributedString(attachment: attachment)
|
||||
attributedString.append(attachmentAttributedString)
|
||||
attributedString.append(NSAttributedString(string: " "))
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineHeightMultiple = 1.12
|
||||
let valueAttributedString = NSAttributedString(
|
||||
string: parseUsersCount(server.totalUsers),
|
||||
attributes: [
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
)
|
||||
attributedString.append(valueAttributedString)
|
||||
|
||||
return attributedString
|
||||
}()
|
||||
cell.langValueLabel.attributedText = {
|
||||
let attributedString = NSMutableAttributedString()
|
||||
let attachment = NSTextAttachment(image: UIImage(systemName: "text.bubble.fill")!)
|
||||
let attachmentAttributedString = NSAttributedString(attachment: attachment)
|
||||
attributedString.append(attachmentAttributedString)
|
||||
attributedString.append(NSAttributedString(string: " "))
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineHeightMultiple = 1.12
|
||||
let valueAttributedString = NSAttributedString(
|
||||
string: server.language.uppercased(),
|
||||
attributes: [
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
)
|
||||
attributedString.append(valueAttributedString)
|
||||
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
attribute.isLast
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] isLast in
|
||||
|
@ -101,41 +123,6 @@ extension PickServerSection {
|
|||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
cell.expandMode
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { mode in
|
||||
switch mode {
|
||||
case .collapse:
|
||||
// do nothing
|
||||
break
|
||||
case .expand:
|
||||
let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill)
|
||||
.af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false)
|
||||
guard let proxiedThumbnail = server.proxiedThumbnail,
|
||||
let url = URL(string: proxiedThumbnail) else {
|
||||
cell.thumbnailImageView.image = placeholderImage
|
||||
cell.thumbnailActivityIndicator.stopAnimating()
|
||||
return
|
||||
}
|
||||
cell.thumbnailImageView.isHidden = false
|
||||
cell.thumbnailActivityIndicator.startAnimating()
|
||||
|
||||
cell.thumbnailImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: placeholderImage,
|
||||
filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3),
|
||||
imageTransition: .crossDissolve(0.33),
|
||||
completion: { [weak cell] response in
|
||||
switch response.result {
|
||||
case .success, .failure:
|
||||
cell?.thumbnailActivityIndicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
private static func parseUsersCount(_ usersCount: Int) -> String {
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// RegisterItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-1-5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RegisterItem: Hashable {
|
||||
case header
|
||||
case avatar
|
||||
case name
|
||||
case username
|
||||
case email
|
||||
case password
|
||||
case hint
|
||||
case reason
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// RegisterSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-1-5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum RegisterSection: Hashable {
|
||||
case main
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// ServerRuleItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-1-5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
enum ServerRuleItem: Hashable {
|
||||
case header(domain: String)
|
||||
case rule(RuleContext)
|
||||
}
|
||||
|
||||
extension ServerRuleItem {
|
||||
struct RuleContext: Hashable {
|
||||
let index: Int
|
||||
let rule: Mastodon.Entity.Instance.Rule
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// ServerRuleSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK on 2022-1-5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum ServerRuleSection: Hashable {
|
||||
case header
|
||||
case rules
|
||||
}
|
||||
|
||||
extension ServerRuleSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
tableView: UITableView
|
||||
) -> UITableViewDiffableDataSource<ServerRuleSection, ServerRuleItem> {
|
||||
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
|
||||
switch item {
|
||||
case .header(let domain):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
|
||||
cell.titleLabel.text = L10n.Scene.ServerRules.title
|
||||
cell.subTitleLabel.text = L10n.Scene.ServerRules.subtitle(domain)
|
||||
return cell
|
||||
case .rule(let ruleContext):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ServerRulesTableViewCell.self), for: indexPath) as! ServerRulesTableViewCell
|
||||
cell.indexImageView.image = UIImage(systemName: "\(ruleContext.index + 1).circle.fill") ?? UIImage(systemName: "questionmark.circle.fill")
|
||||
cell.ruleLabel.text = ruleContext.rule.text
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,10 +18,6 @@ import NaturalLanguage
|
|||
|
||||
// import LinkPresentation
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
#endif
|
||||
|
||||
protocol StatusCell: DisposeBagCollectable {
|
||||
var statusView: StatusView { get }
|
||||
var isFiltered: Bool { get set }
|
||||
|
@ -32,33 +28,6 @@ enum StatusSection: Equatable, Hashable {
|
|||
}
|
||||
|
||||
extension StatusSection {
|
||||
#if ASDK
|
||||
static func tableNodeDiffableDataSource(
|
||||
tableNode: ASTableNode,
|
||||
managedObjectContext: NSManagedObjectContext
|
||||
) -> TableNodeDiffableDataSource<StatusSection, Item> {
|
||||
TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, let attribute):
|
||||
guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
|
||||
return { ASCellNode() }
|
||||
}
|
||||
let status = homeTimelineIndex.status
|
||||
|
||||
return { () -> ASCellNode in
|
||||
let cellNode = StatusNode(status: status)
|
||||
return cellNode
|
||||
}
|
||||
case .homeMiddleLoader:
|
||||
return { TimelineMiddleLoaderNode() }
|
||||
case .bottomLoader:
|
||||
return { TimelineBottomLoaderNode() }
|
||||
default:
|
||||
return { ASCellNode() }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static let logger = Logger(subsystem: "StatusSection", category: "logic")
|
||||
|
|
@ -47,6 +47,7 @@ internal enum Asset {
|
|||
}
|
||||
internal enum Label {
|
||||
internal static let primary = ColorAsset(name: "Colors/Label/primary")
|
||||
internal static let primaryReverse = ColorAsset(name: "Colors/Label/primary.reverse")
|
||||
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
|
||||
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
|
||||
}
|
||||
|
@ -89,6 +90,16 @@ internal enum Asset {
|
|||
internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive")
|
||||
}
|
||||
internal enum Scene {
|
||||
internal enum Onboarding {
|
||||
internal static let avatarPlaceholder = ImageAsset(name: "Scene/Onboarding/avatar.placeholder")
|
||||
internal static let navigationBackButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background")
|
||||
internal static let navigationBackButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.back.button.background.highlighted")
|
||||
internal static let navigationNextButtonBackground = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background")
|
||||
internal static let navigationNextButtonBackgroundHighlighted = ColorAsset(name: "Scene/Onboarding/navigation.next.button.background.highlighted")
|
||||
internal static let onboardingBackground = ColorAsset(name: "Scene/Onboarding/onboarding.background")
|
||||
internal static let searchBarBackground = ColorAsset(name: "Scene/Onboarding/search.bar.background")
|
||||
internal static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background")
|
||||
}
|
||||
internal enum Profile {
|
||||
internal enum Banner {
|
||||
internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray")
|
||||
|
@ -102,8 +113,10 @@ internal enum Asset {
|
|||
internal enum Welcome {
|
||||
internal enum Illustration {
|
||||
internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan")
|
||||
internal static let cloudBaseExtend = ImageAsset(name: "Scene/Welcome/illustration/cloud.base.extend")
|
||||
internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base")
|
||||
internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail")
|
||||
internal static let elephantThreeOnGrassExtend = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.extend")
|
||||
internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass")
|
||||
internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three")
|
||||
internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two")
|
||||
|
@ -112,6 +125,7 @@ internal enum Asset {
|
|||
internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large")
|
||||
internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo")
|
||||
internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
|
||||
internal static let signInButtonBackground = ColorAsset(name: "Scene/Welcome/sign.in.button.background")
|
||||
}
|
||||
}
|
||||
internal enum Settings {
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>1.3.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -30,7 +30,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>88</string>
|
||||
<string>90</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
//
|
||||
// StatusProvider+StatusNodeDelegate.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-6-20.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - StatusViewDelegate
|
||||
extension StatusNodeDelegate where Self: StatusProvider {
|
||||
|
||||
}
|
||||
|
||||
#endif
|
|
@ -10,10 +10,6 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
#endif
|
||||
|
||||
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
|
||||
// async
|
||||
func status() -> Future<Status?, Never>
|
||||
|
@ -31,20 +27,8 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
|
|||
func items(indexPaths: [IndexPath]) -> [Item]
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem]
|
||||
|
||||
#if ASDK
|
||||
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status?
|
||||
#endif
|
||||
}
|
||||
|
||||
#if ASDK
|
||||
extension StatusProvider {
|
||||
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? {
|
||||
fatalError("Needs implement this")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
enum StatusObjectItem {
|
||||
case status(objectID: NSManagedObjectID)
|
||||
case homeTimelineIndex(objectID: NSManagedObjectID)
|
||||
|
|
|
@ -14,10 +14,6 @@ import MastodonSDK
|
|||
import Meta
|
||||
import MetaTextKit
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
#endif
|
||||
|
||||
enum StatusProviderFacade { }
|
||||
|
||||
extension StatusProviderFacade {
|
||||
|
@ -154,13 +150,6 @@ extension StatusProviderFacade {
|
|||
}
|
||||
}
|
||||
|
||||
#if ASDK
|
||||
private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, node: ASCellNode, mention: String) {
|
||||
guard let status = provider.status(node: node, indexPath: nil) else { return }
|
||||
coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention, href: nil)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String, href: String?) {
|
||||
provider.status(for: cell, indexPath: nil)
|
||||
.sink { [weak provider] status in
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x00",
|
||||
"green" : "0x00",
|
||||
"red" : "0x00"
|
||||
"blue" : "0.216",
|
||||
"green" : "0.173",
|
||||
"red" : "0.157"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
@ -23,9 +23,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
"blue" : "0xEE",
|
||||
"green" : "0xEE",
|
||||
"red" : "0xEE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.933",
|
||||
"green" : "0.933",
|
||||
"red" : "0.933"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.216",
|
||||
"green" : "0.173",
|
||||
"red" : "0.157"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -22,10 +22,10 @@
|
|||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.600",
|
||||
"blue" : "0xF5",
|
||||
"green" : "0xEB",
|
||||
"red" : "0xEB"
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xAD",
|
||||
"green" : "0x9D",
|
||||
"red" : "0x97"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
23
Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame 82.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame 82@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame 82@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82.jpg
vendored
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@2x.png
vendored
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
Mastodon/Resources/Assets.xcassets/Scene/Onboarding/avatar.placeholder.imageset/Frame 82@3x.png
vendored
Normal file
After Width: | Height: | Size: 7.3 KiB |
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.200",
|
||||
"blue" : "0x80",
|
||||
"green" : "0x78",
|
||||
"red" : "0x78"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE5",
|
||||
"green" : "0xE5",
|
||||
"red" : "0xE5"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.400",
|
||||
"blue" : "0x80",
|
||||
"green" : "0x78",
|
||||
"red" : "0x78"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x37",
|
||||
"green" : "0x2C",
|
||||
"red" : "0x28"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEE",
|
||||
"green" : "0xEE",
|
||||
"red" : "0xEE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x1B",
|
||||
"green" : "0x15",
|
||||
"red" : "0x13"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xBA",
|
||||
"green" : "0xBA",
|
||||
"red" : "0xBA"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF7",
|
||||
"green" : "0xF2",
|
||||
"red" : "0xF2"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x21",
|
||||
"green" : "0x1B",
|
||||
"red" : "0x19"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.200",
|
||||
"blue" : "0x80",
|
||||
"green" : "0x78",
|
||||
"red" : "0x78"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.240",
|
||||
"blue" : "0x80",
|
||||
"green" : "0x76",
|
||||
"red" : "0x76"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x37",
|
||||
"green" : "0x2C",
|
||||
"red" : "0x28"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cloud.base.extend.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "cloud.base.extend@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "cloud.base.extend@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 73 KiB |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "elephant.three.on.grass.extend.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "elephant.three.on.grass.extend@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "elephant.three.on.grass.extend@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 152 KiB |
After Width: | Height: | Size: 280 KiB |
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x81",
|
||||
"green" : "0xAC",
|
||||
"red" : "0x58"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.922",
|
||||
"green" : "0.898",
|
||||
"red" : "0.867"
|
||||
"blue" : "0xEB",
|
||||
"green" : "0xE4",
|
||||
"red" : "0xDD"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.910",
|
||||
"green" : "0.882",
|
||||
"red" : "0.851"
|
||||
"blue" : "0xE8",
|
||||
"green" : "0xE0",
|
||||
"red" : "0xD9"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.910",
|
||||
"green" : "0.882",
|
||||
"red" : "0.851"
|
||||
"blue" : "0xE8",
|
||||
"green" : "0xE0",
|
||||
"red" : "0xD9"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
@ -1,384 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewController+DebugAction.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK && DEBUG
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import FLEX
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
var debugMenu: UIMenu {
|
||||
let menu = UIMenu(
|
||||
title: "Debug Tools",
|
||||
image: nil,
|
||||
identifier: nil,
|
||||
options: .displayInline,
|
||||
children: [
|
||||
UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showFLEXAction(action)
|
||||
}),
|
||||
moveMenu,
|
||||
dropMenu,
|
||||
UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showWelcomeAction(action)
|
||||
},
|
||||
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
if self.emptyView.superview != nil {
|
||||
self.emptyView.removeFromSuperview()
|
||||
} else {
|
||||
self.showEmptyView()
|
||||
}
|
||||
},
|
||||
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showPublicTimelineAction(action)
|
||||
},
|
||||
UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showProfileAction(action)
|
||||
},
|
||||
UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showThreadAction(action)
|
||||
},
|
||||
UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.showSettings(action)
|
||||
},
|
||||
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.signOutAction(action)
|
||||
}
|
||||
]
|
||||
)
|
||||
return menu
|
||||
}
|
||||
|
||||
var moveMenu: UIMenu {
|
||||
return UIMenu(
|
||||
title: "Move to…",
|
||||
image: UIImage(systemName: "arrow.forward.circle"),
|
||||
identifier: nil,
|
||||
options: [],
|
||||
children: [
|
||||
UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToTopGapAction(action)
|
||||
}),
|
||||
UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstRepliedStatus(action)
|
||||
}),
|
||||
UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstReblogStatus(action)
|
||||
}),
|
||||
UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstPollStatus(action)
|
||||
}),
|
||||
UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstAudioStatus(action)
|
||||
}),
|
||||
UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstVideoStatus(action)
|
||||
}),
|
||||
UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.moveToFirstGIFStatus(action)
|
||||
}),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
var dropMenu: UIMenu {
|
||||
return UIMenu(
|
||||
title: "Drop…",
|
||||
image: UIImage(systemName: "minus.circle"),
|
||||
identifier: nil,
|
||||
options: [],
|
||||
children: [10, 50, 100, 150, 200, 250, 300].map { count in
|
||||
UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.dropRecentStatusAction(action, count: count)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
|
||||
@objc private func showFLEXAction(_ sender: UIAction) {
|
||||
FLEXManager.shared.showExplorer()
|
||||
}
|
||||
|
||||
@objc private func moveToTopGapAction(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeMiddleLoader: return true
|
||||
default: return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstReblogStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
return homeTimelineIndex.status.reblog != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found reblog status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstPollStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
|
||||
return post.poll != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found poll status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstRepliedStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
guard homeTimelineIndex.status.inReplyToID != nil else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found replied status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstAudioStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
|
||||
return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found audio status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstVideoStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
|
||||
return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found video status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToFirstGIFStatus(_ sender: UIAction) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
let item = snapshotTransitioning.itemIdentifiers.first(where: { item in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status
|
||||
return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) {
|
||||
tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
|
||||
tableView.blinkRow(at: IndexPath(row: index, section: 0))
|
||||
} else {
|
||||
print("Not found GIF status")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let snapshotTransitioning = diffableDataSource.snapshot()
|
||||
|
||||
let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _): return objectID
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
var droppingStatusObjectIDs: [NSManagedObjectID] = []
|
||||
context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
|
||||
guard let self = self else { return }
|
||||
for objectID in droppingObjectIDs {
|
||||
guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue }
|
||||
droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID)
|
||||
self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex)
|
||||
}
|
||||
}
|
||||
.sink { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in
|
||||
guard let self = self else { return }
|
||||
for objectID in droppingStatusObjectIDs {
|
||||
guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue }
|
||||
self.context.apiService.backgroundManagedObjectContext.delete(post)
|
||||
}
|
||||
}
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
case .failure(let error):
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
@objc private func showWelcomeAction(_ sender: UIAction) {
|
||||
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func showPublicTimelineAction(_ sender: UIAction) {
|
||||
coordinator.present(scene: .publicTimeline, from: self, transition: .show)
|
||||
}
|
||||
|
||||
@objc private func showProfileAction(_ sender: UIAction) {
|
||||
let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
|
||||
alertController.addTextField()
|
||||
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
|
||||
guard let self = self else { return }
|
||||
guard let textField = alertController?.textFields?.first else { return }
|
||||
let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
|
||||
self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
|
||||
}
|
||||
alertController.addAction(showAction)
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
|
||||
alertController.addAction(cancelAction)
|
||||
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func showThreadAction(_ sender: UIAction) {
|
||||
let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert)
|
||||
alertController.addTextField()
|
||||
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
|
||||
guard let self = self else { return }
|
||||
guard let textField = alertController?.textFields?.first else { return }
|
||||
let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "")
|
||||
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
|
||||
}
|
||||
alertController.addAction(showAction)
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
|
||||
alertController.addAction(cancelAction)
|
||||
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func showSettings(_ sender: UIAction) {
|
||||
guard let currentSetting = context.settingService.currentSetting.value else { return }
|
||||
let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting)
|
||||
coordinator.present(
|
||||
scene: .settings(viewModel: settingsViewModel),
|
||||
from: self,
|
||||
transition: .modal(animated: true, completion: nil)
|
||||
)
|
||||
}
|
||||
|
||||
@objc func signOutAction(_ sender: UIAction) {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
|
||||
context.authenticationService.signOutMastodonUser(
|
||||
domain: activeMastodonAuthenticationBox.domain,
|
||||
userID: activeMastodonAuthenticationBox.userID
|
||||
)
|
||||
.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 isSignOut):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail")
|
||||
guard isSignOut else { return }
|
||||
self.coordinator.setup()
|
||||
self.coordinator.setupOnboardingIfNeeds(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,123 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewController+Provider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import AsyncDisplayKit
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension AsyncHomeTimelineViewController: StatusProvider {
|
||||
|
||||
func status() -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||
return Future { promise in
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext
|
||||
managedObjectContext.perform {
|
||||
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
|
||||
promise(.success(timelineIndex?.status))
|
||||
}
|
||||
default:
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
var managedObjectContext: NSManagedObjectContext {
|
||||
return viewModel.fetchedResultsController.managedObjectContext
|
||||
}
|
||||
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return []
|
||||
}
|
||||
|
||||
var items: [Item] = []
|
||||
for indexPath in indexPaths {
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
|
||||
items.append(item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let indexPath = indexPath ?? node.flatMap({ self.node.indexPath(for: $0) }),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
guard let homeTimelineIndex = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
return homeTimelineIndex.status
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] }
|
||||
let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem }
|
||||
return items
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController: UserProvider {}
|
||||
|
||||
#endif
|
|
@ -1,573 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import AlamofireImage
|
||||
import AsyncDisplayKit
|
||||
|
||||
final class AsyncHomeTimelineViewController: ASDKViewController<ASTableNode>, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = AsyncHomeTimelineViewModel(context: context)
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
lazy var emptyView: UIStackView = {
|
||||
let emptyView = UIStackView()
|
||||
emptyView.axis = .vertical
|
||||
emptyView.distribution = .fill
|
||||
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
|
||||
emptyView.isLayoutMarginsRelativeArrangement = true
|
||||
return emptyView
|
||||
}()
|
||||
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
|
||||
let settingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.tintColor = Asset.Colors.brandBlue.color
|
||||
barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate)
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
let composeBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.tintColor = Asset.Colors.brandBlue.color
|
||||
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
var tableView: UITableView { node.view }
|
||||
|
||||
let publishProgressView: UIProgressView = {
|
||||
let progressView = UIProgressView(progressViewStyle: .bar)
|
||||
progressView.alpha = 0
|
||||
return progressView
|
||||
}()
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
|
||||
override init() {
|
||||
super.init(node: ASTableNode())
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
node.allowsSelection = true
|
||||
|
||||
title = L10n.Scene.HomeTimeline.title
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
|
||||
navigationItem.leftBarButtonItem = settingBarButtonItem
|
||||
navigationItem.titleView = titleView
|
||||
titleView.delegate = self
|
||||
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.state
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
self.titleView.configure(state: state)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
#if DEBUG
|
||||
// long press to trigger debug menu
|
||||
settingBarButtonItem.menu = debugMenu
|
||||
#else
|
||||
settingBarButtonItem.target = self
|
||||
settingBarButtonItem.action = #selector(AsyncHomeTimelineViewController.settingBarButtonItemPressed(_:))
|
||||
#endif
|
||||
|
||||
navigationItem.rightBarButtonItem = composeBarButtonItem
|
||||
composeBarButtonItem.target = self
|
||||
composeBarButtonItem.action = #selector(AsyncHomeTimelineViewController.composeBarButtonItemPressed(_:))
|
||||
|
||||
node.view.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(AsyncHomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||
//
|
||||
// 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),
|
||||
// ])
|
||||
//
|
||||
// publishProgressView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(publishProgressView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||
// publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
// publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
// ])
|
||||
//
|
||||
// viewModel.tableView = tableView
|
||||
viewModel.tableNode = node
|
||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||
node.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
tableNode: node,
|
||||
dependency: self,
|
||||
statusTableViewCellDelegate: self,
|
||||
timelineMiddleLoaderTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
|
||||
// tableView.delegate = self
|
||||
// tableView.prefetchDataSource = self
|
||||
|
||||
// bind refresh control
|
||||
viewModel.isFetchingLatestTimeline
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isFetching in
|
||||
guard let self = self else { return }
|
||||
if !isFetching {
|
||||
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.refreshControl.endRefreshing()
|
||||
} completion: { _ in }
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] progress in
|
||||
// guard let self = self else { return }
|
||||
// guard progress > 0 else {
|
||||
// let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
|
||||
// dismissAnimator.addAnimations {
|
||||
// self.publishProgressView.alpha = 0
|
||||
// }
|
||||
// dismissAnimator.addCompletion { _ in
|
||||
// self.publishProgressView.setProgress(0, animated: false)
|
||||
// }
|
||||
// dismissAnimator.startAnimation()
|
||||
// return
|
||||
// }
|
||||
// if self.publishProgressView.alpha == 0 {
|
||||
// let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
|
||||
// progressAnimator.addAnimations {
|
||||
// self.publishProgressView.alpha = 1
|
||||
// }
|
||||
// progressAnimator.startAnimation()
|
||||
// }
|
||||
//
|
||||
// self.publishProgressView.setProgress(progress, animated: true)
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
//
|
||||
// viewModel.timelineIsEmpty
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak self] isEmpty in
|
||||
// if isEmpty {
|
||||
// self?.showEmptyView()
|
||||
// } else {
|
||||
// self?.emptyView.removeFromSuperview()
|
||||
// }
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// aspectViewWillAppear(animated)
|
||||
//
|
||||
// // needs trigger manually after onboarding dismiss
|
||||
// setNeedsStatusBarAppearanceUpdate()
|
||||
//
|
||||
// if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
|
||||
// viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
|
||||
// }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
// viewModel.viewDidAppear.send()
|
||||
//
|
||||
// DispatchQueue.main.async { [weak self] in
|
||||
// guard let self = self else { return }
|
||||
// if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
|
||||
// self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
// aspectViewDidDisappear(animated)
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
|
||||
// coordinator.animate { _ in
|
||||
// // do nothing
|
||||
// } completion: { _ in
|
||||
// // fix AutoLayout cell height not update after rotate issue
|
||||
// self.viewModel.cellFrameCache.removeAllObjects()
|
||||
// self.tableView.reloadData()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
func showEmptyView() {
|
||||
if emptyView.superview != nil {
|
||||
return
|
||||
}
|
||||
view.addSubview(emptyView)
|
||||
emptyView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
||||
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
||||
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
|
||||
])
|
||||
|
||||
if emptyView.arrangedSubviews.count > 0 {
|
||||
return
|
||||
}
|
||||
let findPeopleButton: PrimaryActionButton = {
|
||||
let button = PrimaryActionButton()
|
||||
button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal)
|
||||
button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
NSLayoutConstraint.activate([
|
||||
findPeopleButton.heightAnchor.constraint(equalToConstant: 46)
|
||||
])
|
||||
|
||||
let manuallySearchButton: HighlightDimmableButton = {
|
||||
let button = HighlightDimmableButton()
|
||||
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
|
||||
button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal)
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
emptyView.addArrangedSubview(findPeopleButton)
|
||||
emptyView.setCustomSpacing(17, after: findPeopleButton)
|
||||
emptyView.addArrangedSubview(manuallySearchButton)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
|
||||
@objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
|
||||
let viewModel = SuggestionAccountViewModel(context: context)
|
||||
viewModel.delegate = self.viewModel
|
||||
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
|
||||
coordinator.switchToTabBar(tab: .search)
|
||||
}
|
||||
|
||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let setting = context.settingService.currentSetting.value else { return }
|
||||
let settingsViewModel = SettingsViewModel(context: context, setting: setting)
|
||||
coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
let composeViewModel = ComposeViewModel(context: context, composeKind: .post)
|
||||
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else {
|
||||
sender.endRefreshing()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewControllerAspect
|
||||
//extension AsyncHomeTimelineViewController: StatusTableViewControllerAspect { }
|
||||
|
||||
//extension AsyncHomeTimelineViewController: TableViewCellHeightCacheableContainer {
|
||||
// var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache }
|
||||
//}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
extension AsyncHomeTimelineViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
|
||||
//aspectScrollViewDidScroll(scrollView)
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
//extension AsyncHomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
||||
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading
|
||||
// var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||
// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadOldestStateMachine }
|
||||
//}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
//extension AsyncHomeTimelineViewController: UITableViewDelegate {
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
// aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
// aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
// }
|
||||
//
|
||||
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
//extension AsyncHomeTimelineViewController: UITableViewDataSourcePrefetching {
|
||||
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
// aspectTableView(tableView, prefetchRowsAt: indexPaths)
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||
extension AsyncHomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
||||
func navigationBar() -> UINavigationBar? {
|
||||
return navigationController?.navigationBar
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
|
||||
extension AsyncHomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
|
||||
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
|
||||
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
|
||||
return
|
||||
}
|
||||
viewModel.loadMiddleSateMachineList
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] ids in
|
||||
guard let _ = self else { return }
|
||||
if let stateMachine = ids[upperTimelineIndexObjectID] {
|
||||
guard let state = stateMachine.currentState else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
// make success state same as loading due to snapshot updating delay
|
||||
let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success
|
||||
if isLoading {
|
||||
cell.startAnimating()
|
||||
} else {
|
||||
cell.stopAnimating()
|
||||
}
|
||||
} else {
|
||||
cell.stopAnimating()
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
var dict = viewModel.loadMiddleSateMachineList.value
|
||||
if let _ = dict[upperTimelineIndexObjectID] {
|
||||
// do nothing
|
||||
} else {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
AsyncHomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
|
||||
AsyncHomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
|
||||
AsyncHomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
|
||||
AsyncHomeTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID),
|
||||
])
|
||||
stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Initial.self)
|
||||
dict[upperTimelineIndexObjectID] = stateMachine
|
||||
viewModel.loadMiddleSateMachineList.value = dict
|
||||
}
|
||||
}
|
||||
|
||||
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
switch item {
|
||||
case .homeMiddleLoader(let upper):
|
||||
guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Loading.self)
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ScrollViewContainer
|
||||
extension AsyncHomeTimelineViewController: ScrollViewContainer {
|
||||
|
||||
var scrollView: UIScrollView { return tableView }
|
||||
|
||||
func scrollToTop(animated: Bool) {
|
||||
if scrollView.contentOffset.y < scrollView.frame.height,
|
||||
viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self),
|
||||
(scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0,
|
||||
!refreshControl.isRefreshing {
|
||||
scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.refreshControl.beginRefreshing()
|
||||
self.refreshControl.sendActions(for: .valueChanged)
|
||||
}
|
||||
} else {
|
||||
let indexPath = IndexPath(row: 0, section: 0)
|
||||
guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return }
|
||||
node.scrollToRow(at: indexPath, at: .top, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
extension AsyncHomeTimelineViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
extension AsyncHomeTimelineViewController: StatusTableViewCellDelegate {
|
||||
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||
func parent() -> UIViewController { return self }
|
||||
}
|
||||
|
||||
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
|
||||
extension AsyncHomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) {
|
||||
scrollToTop(animated: true)
|
||||
}
|
||||
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
|
||||
switch titleView.state {
|
||||
case .newPostButton:
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let indexPath = IndexPath(row: 0, section: 0)
|
||||
guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
|
||||
node.scrollToRow(at: indexPath, at: .top, animated: true)
|
||||
case .offlineButton:
|
||||
// TODO: retry
|
||||
break
|
||||
case .publishedButton:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return navigationKeyCommands + statusNavigationKeyCommands
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewControllerNavigateable
|
||||
extension AsyncHomeTimelineViewController: StatusTableViewControllerNavigateable {
|
||||
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||
navigateKeyCommandHandler(sender)
|
||||
}
|
||||
|
||||
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||
statusKeyCommandHandler(sender)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - ASTableDelegate
|
||||
extension AsyncHomeTimelineViewController: ASTableDelegate {
|
||||
func shouldBatchFetch(for tableNode: ASTableNode) -> Bool {
|
||||
switch viewModel.loadLatestStateMachine.currentState {
|
||||
case is HomeTimelineViewModel.LoadOldestState.NoMore:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) {
|
||||
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
|
||||
context.completeBatchFetching(true)
|
||||
}
|
||||
|
||||
func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) {
|
||||
if let statusNode = node as? StatusNode {
|
||||
statusNode.delegate = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StatusNodeDelegate
|
||||
extension AsyncHomeTimelineViewController: StatusNodeDelegate { }
|
||||
|
||||
#endif
|
|
@ -1,159 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewModel+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import AsyncDisplayKit
|
||||
import DifferenceKit
|
||||
import DiffableDataSources
|
||||
|
||||
extension AsyncHomeTimelineViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
tableNode: ASTableNode,
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
|
||||
) {
|
||||
tableNode.automaticallyAdjustsContentOffset = true
|
||||
|
||||
diffableDataSource = StatusSection.tableNodeDiffableDataSource(
|
||||
tableNode: tableNode,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext
|
||||
)
|
||||
|
||||
var snapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
diffableDataSource?.apply(snapshot)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
extension AsyncHomeTimelineViewModel: NSFetchedResultsControllerDelegate {
|
||||
|
||||
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
|
||||
let predicate = fetchedResultsController.fetchRequest.predicate
|
||||
let parentManagedObjectContext = fetchedResultsController.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
|
||||
managedObjectContext.perform {
|
||||
var shouldAddBottomLoader = false
|
||||
|
||||
let timelineIndexes: [HomeTimelineIndex] = {
|
||||
let request = HomeTimelineIndex.sortedFetchRequest
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.predicate = predicate
|
||||
do {
|
||||
return try managedObjectContext.fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}()
|
||||
|
||||
// that's will be the most fastest fetch because of upstream just update and no modify needs consider
|
||||
|
||||
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .homeTimelineIndex(objectID, attribute) = item else { continue }
|
||||
oldSnapshotAttributeDict[objectID] = attribute
|
||||
}
|
||||
|
||||
var newTimelineItems: [Item] = []
|
||||
|
||||
for (i, timelineIndex) in timelineIndexes.enumerated() {
|
||||
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute()
|
||||
attribute.isSeparatorLineHidden = false
|
||||
|
||||
// append new item into snapshot
|
||||
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))
|
||||
|
||||
let isLast = i == timelineIndexes.count - 1
|
||||
switch (isLast, timelineIndex.hasMore) {
|
||||
case (false, true):
|
||||
newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID))
|
||||
attribute.isSeparatorLineHidden = true
|
||||
case (true, true):
|
||||
shouldAddBottomLoader = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
} // end for
|
||||
|
||||
var newSnapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
|
||||
newSnapshot.appendSections([.main])
|
||||
newSnapshot.appendItems(newTimelineItems, toSection: .main)
|
||||
|
||||
let endSnapshot = CACurrentMediaTime()
|
||||
|
||||
if shouldAddBottomLoader, !(self.loadLatestStateMachine.currentState is LoadOldestState.NoMore) {
|
||||
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
}
|
||||
|
||||
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.isFetchingLatestTimeline.value = false
|
||||
}
|
||||
|
||||
let end = CACurrentMediaTime()
|
||||
os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot)
|
||||
}
|
||||
} // end perform
|
||||
}
|
||||
|
||||
private struct Difference<T> {
|
||||
let item: T
|
||||
let sourceIndexPath: IndexPath
|
||||
let targetIndexPath: IndexPath
|
||||
let offset: CGFloat
|
||||
}
|
||||
|
||||
private func calculateReloadSnapshotDifference<T: Hashable>(
|
||||
navigationBar: UINavigationBar,
|
||||
tableView: UITableView,
|
||||
oldSnapshot: DiffableDataSourceSnapshot<StatusSection, T>,
|
||||
newSnapshot: DiffableDataSourceSnapshot<StatusSection, T>
|
||||
) -> Difference<T>? {
|
||||
guard oldSnapshot.numberOfItems != 0 else { return nil }
|
||||
|
||||
// old snapshot not empty. set source index path to first item if not match
|
||||
let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0)
|
||||
|
||||
guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
|
||||
|
||||
let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
|
||||
guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil }
|
||||
let targetIndexPath = IndexPath(row: itemIndex, section: 0)
|
||||
|
||||
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
|
||||
return Difference(
|
||||
item: timelineItem,
|
||||
sourceIndexPath: sourceIndexPath,
|
||||
targetIndexPath: targetIndexPath,
|
||||
offset: offset
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,134 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewModel+LoadLatestState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import func QuartzCore.CACurrentMediaTime
|
||||
import Foundation
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
|
||||
extension AsyncHomeTimelineViewModel {
|
||||
class LoadLatestState: GKState {
|
||||
weak var viewModel: AsyncHomeTimelineViewModel?
|
||||
|
||||
init(viewModel: AsyncHomeTimelineViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
viewModel?.loadLatestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewModel.LoadLatestState {
|
||||
class Initial: AsyncHomeTimelineViewModel.LoadLatestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: AsyncHomeTimelineViewModel.LoadLatestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Fail.self || stateClass == Idle.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
// sign out when loading will enter here
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let predicate = viewModel.fetchedResultsController.fetchRequest.predicate
|
||||
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext
|
||||
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
managedObjectContext.parent = parentManagedObjectContext
|
||||
|
||||
managedObjectContext.perform {
|
||||
let start = CACurrentMediaTime()
|
||||
let latestStatusIDs: [Status.ID]
|
||||
let request = HomeTimelineIndex.sortedFetchRequest
|
||||
request.returnsObjectsAsFaults = false
|
||||
request.predicate = predicate
|
||||
|
||||
do {
|
||||
let timelineIndexes = try managedObjectContext.fetch(request)
|
||||
let endFetch = CACurrentMediaTime()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start)
|
||||
latestStatusIDs = timelineIndexes
|
||||
.prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue
|
||||
.compactMap { timelineIndex in
|
||||
timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID
|
||||
}
|
||||
} catch {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
let end = CACurrentMediaTime()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||
|
||||
// TODO: only set large count when using Wi-Fi
|
||||
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
// TODO: handle error
|
||||
viewModel.isFetchingLatestTimeline.value = false
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
|
||||
stateMachine.enter(Idle.self)
|
||||
|
||||
} receiveValue: { response in
|
||||
// stop refresher if no new statuses
|
||||
let statuses = response.value
|
||||
let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) }
|
||||
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count)
|
||||
|
||||
if newStatuses.isEmpty {
|
||||
viewModel.isFetchingLatestTimeline.value = false
|
||||
} else {
|
||||
if !latestStatusIDs.isEmpty {
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
||||
}
|
||||
}
|
||||
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: AsyncHomeTimelineViewModel.LoadLatestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self || stateClass == Idle.self
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: AsyncHomeTimelineViewModel.LoadLatestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,112 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewModel+LoadMiddleState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
extension AsyncHomeTimelineViewModel {
|
||||
class LoadMiddleState: GKState {
|
||||
weak var viewModel: AsyncHomeTimelineViewModel?
|
||||
let upperTimelineIndexObjectID: NSManagedObjectID
|
||||
|
||||
init(viewModel: AsyncHomeTimelineViewModel, upperTimelineIndexObjectID: NSManagedObjectID) {
|
||||
self.viewModel = viewModel
|
||||
self.upperTimelineIndexObjectID = upperTimelineIndexObjectID
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
var dict = viewModel.loadMiddleSateMachineList.value
|
||||
dict[upperTimelineIndexObjectID] = stateMachine
|
||||
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewModel.LoadMiddleState {
|
||||
|
||||
class Initial: AsyncHomeTimelineViewModel.LoadMiddleState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: AsyncHomeTimelineViewModel.LoadMiddleState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// guard let viewModel = viewModel else { return false }
|
||||
return stateClass == Success.self || stateClass == Fail.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
guard let timelineIndex = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperTimelineIndexObjectID }) else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in
|
||||
timelineIndex.status.id
|
||||
}
|
||||
|
||||
// TODO: only set large count when using Wi-Fi
|
||||
let maxID = timelineIndex.status.id
|
||||
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
|
||||
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
// TODO: handle error
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { response in
|
||||
let statuses = response.value
|
||||
let newStatuses = statuses.filter { !statusIDs.contains($0.id) }
|
||||
os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count)
|
||||
if newStatuses.isEmpty {
|
||||
stateMachine.enter(Fail.self)
|
||||
} else {
|
||||
stateMachine.enter(Success.self)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: AsyncHomeTimelineViewModel.LoadMiddleState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// guard let viewModel = viewModel else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Success: AsyncHomeTimelineViewModel.LoadMiddleState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// guard let viewModel = viewModel else { return false }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,117 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewModel+LoadOldestState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
|
||||
extension AsyncHomeTimelineViewModel {
|
||||
class LoadOldestState: GKState {
|
||||
weak var viewModel: AsyncHomeTimelineViewModel?
|
||||
|
||||
init(viewModel: AsyncHomeTimelineViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
class Initial: AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else {
|
||||
stateMachine.enter(Idle.self)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: only set large count when using Wi-Fi
|
||||
let maxID = last.status.id
|
||||
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox)
|
||||
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
} receiveValue: { response in
|
||||
let statuses = response.value
|
||||
// enter no more state when no new statuses
|
||||
if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self || stateClass == Idle.self
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: AsyncHomeTimelineViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// reset state if needs
|
||||
return stateClass == Idle.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.deleteItems([.bottomLoader])
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,151 +0,0 @@
|
|||
//
|
||||
// AsyncHomeTimelineViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-21.
|
||||
//
|
||||
//
|
||||
|
||||
#if ASDK
|
||||
|
||||
import os.log
|
||||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
import AlamofireImage
|
||||
import DateToolsSwift
|
||||
import AsyncDisplayKit
|
||||
|
||||
final class AsyncHomeTimelineViewModel: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
|
||||
let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex>
|
||||
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
|
||||
|
||||
weak var tableNode: ASTableNode?
|
||||
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
|
||||
//weak var tableView: UITableView?
|
||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||
|
||||
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
|
||||
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
var diffableDataSource: TableNodeDiffableDataSource<StatusSection, Item>?
|
||||
|
||||
// top loader
|
||||
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
LoadLatestState.Initial(viewModel: self),
|
||||
LoadLatestState.Loading(viewModel: self),
|
||||
LoadLatestState.Fail(viewModel: self),
|
||||
LoadLatestState.Idle(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(LoadLatestState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
|
||||
// bottom loader
|
||||
private(set) lazy var loadOldestStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
LoadOldestState.Initial(viewModel: self),
|
||||
LoadOldestState.Loading(viewModel: self),
|
||||
LoadOldestState.Fail(viewModel: self),
|
||||
LoadOldestState.Idle(viewModel: self),
|
||||
LoadOldestState.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(LoadOldestState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
|
||||
// middle loader
|
||||
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
|
||||
// var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
self.fetchedResultsController = {
|
||||
let fetchRequest = HomeTimelineIndex.sortedFetchRequest
|
||||
fetchRequest.fetchBatchSize = 20
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)]
|
||||
let controller = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
return controller
|
||||
}()
|
||||
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
|
||||
timelinePredicate
|
||||
.receive(on: DispatchQueue.main)
|
||||
.compactMap { $0 }
|
||||
.first() // set once
|
||||
.sink { [weak self] predicate in
|
||||
guard let self = self else { return }
|
||||
self.fetchedResultsController.fetchRequest.predicate = predicate
|
||||
do {
|
||||
try self.fetchedResultsController.performFetch()
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
context.authenticationService.activeMastodonAuthentication
|
||||
.sink { [weak self] activeMastodonAuthentication in
|
||||
guard let self = self else { return }
|
||||
guard let mastodonAuthentication = activeMastodonAuthentication else { return }
|
||||
let activeMastodonUserID = mastodonAuthentication.userID
|
||||
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
HomeTimelineIndex.predicate(userID: activeMastodonUserID),
|
||||
HomeTimelineIndex.notDeleted()
|
||||
])
|
||||
self.timelinePredicate.value = predicate
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
homeTimelineNeedRefresh
|
||||
.sink { [weak self] _ in
|
||||
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
homeTimelineNavigationBarTitleViewModel.isPublished
|
||||
.sink { [weak self] isPublished in
|
||||
guard let self = self else { return }
|
||||
self.homeTimelineNeedRefresh.send()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AsyncHomeTimelineViewModel: SuggestionAccountViewModelDelegate { }
|
||||
|
||||
#endif
|
|
@ -46,21 +46,11 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc
|
|||
imageView.contentMode = .scaleAspectFit
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let openEmailButton: UIButton = {
|
||||
let button = PrimaryActionButton()
|
||||
button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
|
||||
button.addTarget(self, action: #selector(openEmailButtonPressed(_:)), for: UIControl.Event.touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
let dontReceiveButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15))
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal)
|
||||
button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside)
|
||||
return button
|
||||
|
||||
let navigationActionView: NavigationActionView = {
|
||||
let navigationActionView = NavigationActionView()
|
||||
navigationActionView.backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
|
||||
return navigationActionView
|
||||
}()
|
||||
|
||||
deinit {
|
||||
|
@ -73,6 +63,8 @@ extension MastodonConfirmEmailViewController {
|
|||
|
||||
override func viewDidLoad() {
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem()
|
||||
|
||||
setupOnboardingAppearance()
|
||||
configureTitleLabel()
|
||||
configureMargin()
|
||||
|
@ -83,13 +75,12 @@ extension MastodonConfirmEmailViewController {
|
|||
stackView.spacing = 10
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 10, left: 0, bottom: 23, right: 0)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.addArrangedSubview(self.largeTitleLabel)
|
||||
stackView.addArrangedSubview(self.subtitleLabel)
|
||||
stackView.addArrangedSubview(self.emailImageView)
|
||||
stackView.addArrangedSubview(largeTitleLabel)
|
||||
stackView.addArrangedSubview(subtitleLabel)
|
||||
stackView.addArrangedSubview(emailImageView)
|
||||
emailImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
emailImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
stackView.addArrangedSubview(self.openEmailButton)
|
||||
stackView.addArrangedSubview(self.dontReceiveButton)
|
||||
stackView.addArrangedSubview(navigationActionView)
|
||||
|
||||
view.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -99,10 +90,7 @@ extension MastodonConfirmEmailViewController {
|
|||
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor),
|
||||
])
|
||||
NSLayoutConstraint.activate([
|
||||
self.openEmailButton.heightAnchor.constraint(equalToConstant: 46),
|
||||
])
|
||||
|
||||
|
||||
self.viewModel.timestampUpdatePublisher
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
@ -140,6 +128,13 @@ extension MastodonConfirmEmailViewController {
|
|||
.store(in: &self.disposeBag)
|
||||
}
|
||||
.store(in: &self.disposeBag)
|
||||
|
||||
|
||||
navigationActionView.backButton.setTitle("Resend", for: .normal) // TODO: i18n
|
||||
navigationActionView.backButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.resendButtonPressed(_:)), for: .touchUpInside)
|
||||
|
||||
navigationActionView.nextButton.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal)
|
||||
navigationActionView.nextButton.addTarget(self, action: #selector(MastodonConfirmEmailViewController.openEmailButtonPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
|
@ -190,7 +185,7 @@ extension MastodonConfirmEmailViewController {
|
|||
self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
|
||||
}
|
||||
|
||||
@objc private func dontReceiveButtonPressed(_ sender: UIButton) {
|
||||
@objc private func resendButtonPressed(_ sender: UIButton) {
|
||||
let alertController = UIAlertController(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.title, message: L10n.Scene.ConfirmEmail.DontReceiveEmail.description, preferredStyle: .alert)
|
||||
let resendAction = UIAlertAction(title: L10n.Scene.ConfirmEmail.DontReceiveEmail.resendEmail, style: .default) { _ in
|
||||
let url = Mastodon.API.resendEmailURL(domain: self.viewModel.authenticateInfo.domain)
|
||||
|
|
|
@ -8,14 +8,10 @@
|
|||
import UIKit
|
||||
|
||||
class PickServerCategoryCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
var categoryView: PickServerCategoryView = {
|
||||
let view = PickServerCategoryView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
var categoryView = PickServerCategoryView()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
@ -35,13 +31,15 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell {
|
|||
|
||||
extension PickServerCategoryCollectionViewCell {
|
||||
private func configure() {
|
||||
contentView.addSubview(categoryView)
|
||||
backgroundColor = .clear
|
||||
|
||||
categoryView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(categoryView)
|
||||
NSLayoutConstraint.activate([
|
||||
categoryView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
categoryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
categoryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
categoryView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor, constant: 10),
|
||||
contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import AuthenticationServices
|
|||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||
|
||||
private var disposeBag = Set<AnyCancellable>()
|
||||
private var observations = Set<NSKeyValueObservation>()
|
||||
private var tableViewObservation: NSKeyValueObservation?
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -31,21 +32,16 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
private let emptyStateView = PickServerEmptyStateView()
|
||||
private var emptyStateViewLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
private var emptyStateViewTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
|
||||
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
let tableView: UITableView = {
|
||||
let tableView = ControlContainableTableView()
|
||||
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
|
||||
tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self))
|
||||
tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self))
|
||||
tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self))
|
||||
tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self))
|
||||
tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self))
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
if #available(iOS 15.0, *) {
|
||||
tableView.sectionHeaderTopPadding = .leastNonzeroMagnitude
|
||||
} else {
|
||||
|
@ -54,14 +50,11 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
return tableView
|
||||
}()
|
||||
|
||||
let buttonContainer = UIView()
|
||||
let nextStepButton: PrimaryActionButton = {
|
||||
let button = PrimaryActionButton()
|
||||
button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
return button
|
||||
let navigationActionView: NavigationActionView = {
|
||||
let navigationActionView = NavigationActionView()
|
||||
navigationActionView.backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
|
||||
return navigationActionView
|
||||
}()
|
||||
var buttonContainerBottomLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
var mastodonAuthenticationController: MastodonAuthenticationController?
|
||||
|
||||
|
@ -72,16 +65,15 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem()
|
||||
|
||||
setupOnboardingAppearance()
|
||||
defer { setupNavigationBarBackgroundView() }
|
||||
configureTitleLabel()
|
||||
configureMargin()
|
||||
|
||||
#if DEBUG
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil)
|
||||
|
@ -94,26 +86,35 @@ extension MastodonPickServerViewController {
|
|||
navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children)
|
||||
#endif
|
||||
|
||||
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
buttonContainer.preservesSuperviewLayoutMargins = true
|
||||
view.addSubview(buttonContainer)
|
||||
buttonContainerBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor, constant: 0).priority(.defaultHigh)
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
buttonContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
buttonContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
view.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: buttonContainer.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
|
||||
buttonContainerBottomLayoutConstraint,
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
view.addSubview(nextStepButton)
|
||||
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(navigationActionView)
|
||||
defer {
|
||||
view.bringSubviewToFront(navigationActionView)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
nextStepButton.topAnchor.constraint(equalTo: buttonContainer.topAnchor),
|
||||
nextStepButton.leadingAnchor.constraint(equalTo: buttonContainer.layoutMarginsGuide.leadingAnchor),
|
||||
buttonContainer.layoutMarginsGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor),
|
||||
nextStepButton.bottomAnchor.constraint(equalTo: buttonContainer.bottomAnchor),
|
||||
nextStepButton.heightAnchor.constraint(equalToConstant: MastodonPickServerViewController.actionButtonHeight).priority(.defaultHigh),
|
||||
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)
|
||||
|
||||
|
||||
// fix AutoLayout warning when observe before view appear
|
||||
viewModel.viewWillAppear
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -125,26 +126,7 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableViewTopPaddingView)
|
||||
tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh)
|
||||
NSLayoutConstraint.activate([
|
||||
tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||
tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableViewTopPaddingViewHeightLayoutConstraint,
|
||||
])
|
||||
tableViewTopPaddingView.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
buttonContainer.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7),
|
||||
])
|
||||
|
||||
|
||||
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(emptyStateView)
|
||||
emptyStateViewLeadingLayoutConstraint = emptyStateView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor)
|
||||
|
@ -153,64 +135,24 @@ extension MastodonPickServerViewController {
|
|||
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
emptyStateViewLeadingLayoutConstraint,
|
||||
emptyStateViewTrailingLayoutConstraint,
|
||||
buttonContainer.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
|
||||
navigationActionView.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
|
||||
])
|
||||
view.sendSubviewToBack(emptyStateView)
|
||||
|
||||
// update layout when keyboard show/dismiss
|
||||
let keyboardEventPublishers = Publishers.CombineLatest3(
|
||||
KeyboardResponderService.shared.isShow,
|
||||
KeyboardResponderService.shared.state,
|
||||
KeyboardResponderService.shared.endFrame
|
||||
)
|
||||
|
||||
keyboardEventPublishers
|
||||
.sink { [weak self] keyboardEvents in
|
||||
guard let self = self else { return }
|
||||
let (isShow, state, endFrame) = keyboardEvents
|
||||
|
||||
// guard external keyboard connected
|
||||
guard isShow, state == .dock, GCKeyboard.coalesced != nil else {
|
||||
self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight
|
||||
return
|
||||
}
|
||||
|
||||
let externalKeyboardToolbarHeight = self.view.frame.maxY - endFrame.minY
|
||||
guard externalKeyboardToolbarHeight > 0 else {
|
||||
self.buttonContainerBottomLayoutConstraint.constant = WelcomeViewController.viewBottomPaddingHeight
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.buttonContainerBottomLayoutConstraint.constant = externalKeyboardToolbarHeight + 16
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
switch viewModel.mode {
|
||||
case .signIn:
|
||||
nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal)
|
||||
case .signUp:
|
||||
nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal)
|
||||
}
|
||||
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
|
||||
|
||||
|
||||
tableView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: self,
|
||||
pickServerCategoriesCellDelegate: self,
|
||||
pickServerSearchCellDelegate: self,
|
||||
pickServerServerSectionTableHeaderViewDelegate: self,
|
||||
pickServerCellDelegate: self
|
||||
)
|
||||
|
||||
|
||||
viewModel
|
||||
.selectedServer
|
||||
.map { $0 != nil }
|
||||
.assign(to: \.isEnabled, on: nextStepButton)
|
||||
.assign(to: \.isEnabled, on: navigationActionView.nextButton)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
Publishers.Merge(
|
||||
viewModel.error,
|
||||
authenticationViewModel.error
|
||||
|
@ -229,7 +171,7 @@ extension MastodonPickServerViewController {
|
|||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
authenticationViewModel
|
||||
.authenticated
|
||||
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
|
||||
|
@ -249,17 +191,17 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
authenticationViewModel.isAuthenticating
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isAuthenticating in
|
||||
guard let self = self else { return }
|
||||
isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading()
|
||||
isAuthenticating ? self.navigationActionView.nextButton.showLoading() : self.navigationActionView.nextButton.stopLoading()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
viewModel.emptyStateViewState
|
||||
.receive(on: RunLoop.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
switch state {
|
||||
|
@ -284,6 +226,9 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
navigationActionView.backButton.addTarget(self, action: #selector(MastodonPickServerViewController.backButtonDidPressed(_:)), for: .touchUpInside)
|
||||
navigationActionView.nextButton.addTarget(self, action: #selector(MastodonPickServerViewController.nextButtonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -291,43 +236,31 @@ extension MastodonPickServerViewController {
|
|||
viewModel.viewWillAppear.send()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
tableView.flashScrollIndicators()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
setupNavigationBarAppearance()
|
||||
updateEmptyStateViewLayout()
|
||||
configureTitleLabel()
|
||||
configureMargin()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
private func configureTitleLabel() {
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return
|
||||
}
|
||||
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
navigationItem.title = L10n.Scene.ServerPicker.title.replacingOccurrences(of: "\n", with: " ")
|
||||
default:
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
navigationItem.title = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
@objc
|
||||
private func nextStepButtonDidClicked(_ sender: UIButton) {
|
||||
@objc private func backButtonDidPressed(_ sender: UIButton) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@objc private func nextButtonDidPressed(_ sender: UIButton) {
|
||||
switch viewModel.mode {
|
||||
case .signIn:
|
||||
doSignIn()
|
||||
case .signUp:
|
||||
doSignUp()
|
||||
case .signIn: doSignIn()
|
||||
case .signUp: doSignUp()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -442,8 +375,8 @@ extension MastodonPickServerViewController {
|
|||
self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show)
|
||||
} else {
|
||||
let mastodonRegisterViewModel = MastodonRegisterViewModel(
|
||||
domain: server.domain,
|
||||
context: self.context,
|
||||
domain: server.domain,
|
||||
authenticateInfo: response.authenticateInfo,
|
||||
instance: response.instance.value,
|
||||
applicationToken: response.applicationToken.value
|
||||
|
@ -458,16 +391,6 @@ extension MastodonPickServerViewController {
|
|||
// MARK: - UITableViewDelegate
|
||||
extension MastodonPickServerViewController: UITableViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView === tableView else { return }
|
||||
let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
|
||||
if offsetY < 0 {
|
||||
tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY)
|
||||
} else {
|
||||
tableViewTopPaddingViewHeightLayoutConstraint.constant = 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
|
@ -500,87 +423,89 @@ extension MastodonPickServerViewController: UITableViewDelegate {
|
|||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
switch item {
|
||||
case .categoryPicker:
|
||||
guard let cell = cell as? PickServerCategoriesCell else { return }
|
||||
guard let diffableDataSource = cell.diffableDataSource else { return }
|
||||
let snapshot = diffableDataSource.snapshot()
|
||||
|
||||
let item = viewModel.selectCategoryItem.value
|
||||
guard let section = snapshot.indexOfSection(.main),
|
||||
let row = snapshot.indexOfItem(item) else { return }
|
||||
cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
|
||||
case .search:
|
||||
guard let cell = cell as? PickServerSearchCell else { return }
|
||||
cell.searchTextField.text = viewModel.searchText.value
|
||||
// case .categoryPicker:
|
||||
// guard let cell = cell as? PickServerCategoriesCell else { return }
|
||||
// guard let diffableDataSource = cell.diffableDataSource else { return }
|
||||
// let snapshot = diffableDataSource.snapshot()
|
||||
//
|
||||
// let item = viewModel.selectCategoryItem.value
|
||||
// guard let section = snapshot.indexOfSection(.main),
|
||||
// let row = snapshot.indexOfItem(item) else { return }
|
||||
// cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
|
||||
// case .search:
|
||||
// guard let cell = cell as? PickServerSearchCell else { return }
|
||||
// cell.searchTextField.text = viewModel.searchText.value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let snapshot = diffableDataSource.snapshot()
|
||||
guard section < snapshot.numberOfSections else { return nil }
|
||||
let section = snapshot.sectionIdentifiers[section]
|
||||
|
||||
switch section {
|
||||
case .servers:
|
||||
return viewModel.serverSectionHeaderView
|
||||
default:
|
||||
return UIView()
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return .leastNonzeroMagnitude }
|
||||
let snapshot = diffableDataSource.snapshot()
|
||||
guard section < snapshot.numberOfSections else { return .leastNonzeroMagnitude }
|
||||
let section = snapshot.sectionIdentifiers[section]
|
||||
|
||||
switch section {
|
||||
case .servers:
|
||||
return PickServerServerSectionTableHeaderView.height
|
||||
default:
|
||||
return .leastNonzeroMagnitude
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
private func updateEmptyStateViewLayout() {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
|
||||
let rectInTableView = tableView.rectForRow(at: indexPath)
|
||||
|
||||
emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
|
||||
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
emptyStateViewLeadingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
|
||||
emptyStateViewTrailingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
|
||||
default:
|
||||
let margin = tableView.layoutMarginsGuide.layoutFrame.origin.x
|
||||
emptyStateViewLeadingLayoutConstraint.constant = margin
|
||||
emptyStateViewTrailingLayoutConstraint.constant = margin
|
||||
}
|
||||
}
|
||||
|
||||
private func configureMargin() {
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
buttonContainer.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
default:
|
||||
buttonContainer.layoutMargins = .zero
|
||||
}
|
||||
// guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
|
||||
// guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
|
||||
// let rectInTableView = tableView.rectForRow(at: indexPath)
|
||||
//
|
||||
// emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
|
||||
//
|
||||
// switch traitCollection.horizontalSizeClass {
|
||||
// case .regular:
|
||||
// emptyStateViewLeadingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
|
||||
// emptyStateViewTrailingLayoutConstraint.constant = MastodonPickServerViewController.viewEdgeMargin
|
||||
// default:
|
||||
// let margin = tableView.layoutMarginsGuide.layoutFrame.origin.x
|
||||
// emptyStateViewLeadingLayoutConstraint.constant = margin
|
||||
// emptyStateViewTrailingLayoutConstraint.constant = margin
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerCategoriesCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerCategoriesCellDelegate {
|
||||
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = cell.diffableDataSource else { return }
|
||||
// MARK: - PickServerServerSectionTableHeaderViewDelegate
|
||||
extension MastodonPickServerViewController: PickServerServerSectionTableHeaderViewDelegate {
|
||||
func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = headerView.diffableDataSource else { return }
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath)
|
||||
viewModel.selectCategoryItem.value = item ?? .all
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerSearchCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
||||
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
|
||||
|
||||
func pickServerServerSectionTableHeaderView(_ headerView: PickServerServerSectionTableHeaderView, searchTextDidChange searchText: String?) {
|
||||
viewModel.searchText.send(searchText ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerCellDelegate {
|
||||
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard case let .server(_, attribute) = item else { return }
|
||||
|
||||
attribute.isExpand.toggle()
|
||||
tableView.beginUpdates()
|
||||
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
|
||||
tableView.endUpdates()
|
||||
|
||||
// expand attribute change do not needs apply snapshot to diffable data source
|
||||
// but should I block the viewModel data binding during tableView.beginUpdates/endUpdates?
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - OnboardingViewControllerAppearance
|
||||
|
|
|
@ -6,32 +6,105 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
extension MastodonPickServerViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerServerSectionTableHeaderViewDelegate: PickServerServerSectionTableHeaderViewDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) {
|
||||
// set section header
|
||||
serverSectionHeaderView.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
|
||||
for: serverSectionHeaderView.collectionView,
|
||||
dependency: dependency
|
||||
)
|
||||
var sectionHeaderSnapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
|
||||
sectionHeaderSnapshot.appendSections([.main])
|
||||
sectionHeaderSnapshot.appendItems(categoryPickerItems, toSection: .main)
|
||||
serverSectionHeaderView.delegate = pickServerServerSectionTableHeaderViewDelegate
|
||||
serverSectionHeaderView.diffableDataSource?.applySnapshot(sectionHeaderSnapshot, animated: false) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let indexPath = self.serverSectionHeaderView.diffableDataSource?.indexPath(for: .all) else { return }
|
||||
self.serverSectionHeaderView.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally)
|
||||
}
|
||||
|
||||
// set tableView
|
||||
diffableDataSource = PickServerSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: pickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: pickServerCellDelegate
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
|
||||
snapshot.appendSections([.header, .category, .search, .servers])
|
||||
snapshot.appendSections([.header, .servers])
|
||||
snapshot.appendItems([.header], toSection: .header)
|
||||
snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category)
|
||||
snapshot.appendItems([.search], toSection: .search)
|
||||
diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
|
||||
loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
filteredIndexedServers,
|
||||
unindexedServers
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .server(server, attribute) = item else { continue }
|
||||
oldSnapshotServerItemAttributeDict[server.domain] = attribute
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
|
||||
snapshot.appendSections([.header, .servers])
|
||||
snapshot.appendItems([.header], toSection: .header)
|
||||
|
||||
// TODO: handle filter
|
||||
var serverItems: [PickServerItem] = []
|
||||
for server in indexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast.value = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
|
||||
if let unindexedServers = unindexedServers {
|
||||
if !unindexedServers.isEmpty {
|
||||
for server in unindexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast.value = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
} else {
|
||||
if indexedServers.isEmpty && !self.isLoadingIndexedServers.value {
|
||||
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false)))
|
||||
}
|
||||
|
||||
if case let .server(_, attribute) = serverItems.last {
|
||||
attribute.isLast.value = true
|
||||
}
|
||||
if case let .loader(attribute) = serverItems.last {
|
||||
attribute.isLast = true
|
||||
}
|
||||
snapshot.appendItems(serverItems, toSection: .servers)
|
||||
|
||||
diffableDataSource.defaultRowAnimation = .fade
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import GameplayKit
|
|||
import MastodonSDK
|
||||
import CoreDataStack
|
||||
import OrderedCollections
|
||||
import Tabman
|
||||
|
||||
class MastodonPickServerViewModel: NSObject {
|
||||
|
||||
|
@ -27,6 +28,8 @@ class MastodonPickServerViewModel: NSObject {
|
|||
}
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let serverSectionHeaderView = PickServerServerSectionTableHeaderView()
|
||||
|
||||
// input
|
||||
let mode: PickServerMode
|
||||
|
@ -82,68 +85,6 @@ class MastodonPickServerViewModel: NSObject {
|
|||
extension MastodonPickServerViewModel {
|
||||
|
||||
private func configure() {
|
||||
Publishers.CombineLatest(
|
||||
filteredIndexedServers,
|
||||
unindexedServers
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .server(server, attribute) = item else { continue }
|
||||
oldSnapshotServerItemAttributeDict[server.domain] = attribute
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
|
||||
snapshot.appendSections([.header, .category, .search, .servers])
|
||||
snapshot.appendItems([.header], toSection: .header)
|
||||
snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category)
|
||||
snapshot.appendItems([.search], toSection: .search)
|
||||
// TODO: handle filter
|
||||
var serverItems: [PickServerItem] = []
|
||||
for server in indexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast.value = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
|
||||
if let unindexedServers = unindexedServers {
|
||||
if !unindexedServers.isEmpty {
|
||||
for server in unindexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast.value = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
} else {
|
||||
if indexedServers.isEmpty && !self.isLoadingIndexedServers.value {
|
||||
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false)))
|
||||
}
|
||||
|
||||
if case let .server(_, attribute) = serverItems.last {
|
||||
attribute.isLast.value = true
|
||||
}
|
||||
if case let .loader(attribute) = serverItems.last {
|
||||
attribute.isLast = true
|
||||
}
|
||||
snapshot.appendItems(serverItems, toSection: .servers)
|
||||
|
||||
diffableDataSource.defaultRowAnimation = .fade
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
isLoadingIndexedServers,
|
||||
loadingIndexedServersError
|
||||
|
@ -301,3 +242,12 @@ extension MastodonPickServerViewModel {
|
|||
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TMBarDataSource
|
||||
extension MastodonPickServerViewModel: TMBarDataSource {
|
||||
func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
|
||||
let item = categoryPickerItems[index]
|
||||
let barItem = TMBarItem(title: item.title)
|
||||
return barItem
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
//
|
||||
// PickServerCategoriesCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by BradGao on 2021/2/23.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
protocol PickServerCategoriesCellDelegate: AnyObject {
|
||||
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
}
|
||||
|
||||
final class PickServerCategoriesCell: UITableViewCell {
|
||||
|
||||
weak var delegate: PickServerCategoriesCellDelegate?
|
||||
|
||||
var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
|
||||
|
||||
let metricView = UIView()
|
||||
|
||||
let collectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerCategoriesCell {
|
||||
|
||||
private func _init() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
configureMargin()
|
||||
|
||||
metricView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(metricView)
|
||||
NSLayoutConstraint.activate([
|
||||
metricView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
metricView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
metricView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
metricView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
metricView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
contentView.addSubview(collectionView)
|
||||
NSLayoutConstraint.activate([
|
||||
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 20),
|
||||
collectionView.heightAnchor.constraint(equalToConstant: 80).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
collectionView.delegate = self
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
configureMargin()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PickServerCategoriesCell {
|
||||
private func configureMargin() {
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
default:
|
||||
contentView.layoutMargins = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UICollectionViewDelegateFlowLayout
|
||||
extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
|
||||
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
|
||||
delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
layoutIfNeeded()
|
||||
return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||
return 16
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
return CGSize(width: 60, height: 80)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PickServerCategoriesCell {
|
||||
|
||||
override func accessibilityElementCount() -> Int {
|
||||
guard let diffableDataSource = diffableDataSource else { return 0 }
|
||||
return diffableDataSource.snapshot().itemIdentifiers.count
|
||||
}
|
||||
|
||||
override func accessibilityElement(at index: Int) -> Any? {
|
||||
guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil }
|
||||
return item
|
||||
}
|
||||
|
||||
}
|
|
@ -13,7 +13,7 @@ import AlamofireImage
|
|||
import Kanna
|
||||
|
||||
protocol PickServerCellDelegate: AnyObject {
|
||||
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
|
||||
// func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
|
||||
}
|
||||
|
||||
class PickServerCell: UITableViewCell {
|
||||
|
@ -21,20 +21,17 @@ class PickServerCell: UITableViewCell {
|
|||
weak var delegate: PickServerCellDelegate?
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse)
|
||||
|
||||
let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
|
||||
view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let containerView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .vertical
|
||||
view.spacing = 4
|
||||
return view
|
||||
}()
|
||||
|
||||
let domainLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -52,7 +49,7 @@ class PickServerCell: UITableViewCell {
|
|||
|
||||
let descriptionLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
|
||||
label.numberOfLines = 0
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
|
@ -60,112 +57,33 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
let thumbnailActivityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
let thumbnailImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let infoStackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .fill
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.spacing = 16
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let expandBox: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let expandButton: UIButton = {
|
||||
let button = HitTestExpandedButton(type: .custom)
|
||||
button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
|
||||
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
button.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
return button
|
||||
}()
|
||||
|
||||
let separator: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = Asset.Theme.System.separator.color
|
||||
return view
|
||||
}()
|
||||
|
||||
let langValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let usersValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
|
||||
label.textAlignment = .center
|
||||
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .regular))
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let categoryValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27)
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let langTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
|
||||
label.text = L10n.Scene.ServerPicker.Label.language
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let usersTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
|
||||
label.text = L10n.Scene.ServerPicker.Label.users
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
let categoryTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16)
|
||||
label.text = L10n.Scene.ServerPicker.Label.category
|
||||
label.textAlignment = .center
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
|
@ -175,9 +93,6 @@ class PickServerCell: UITableViewCell {
|
|||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
thumbnailImageView.isHidden = false
|
||||
thumbnailImageView.af.cancelImageRequest()
|
||||
thumbnailActivityIndicator.stopAnimating()
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
|
@ -197,172 +112,55 @@ class PickServerCell: UITableViewCell {
|
|||
extension PickServerCell {
|
||||
private func _init() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
configureMargin()
|
||||
backgroundColor = Asset.Scene.Onboarding.onboardingBackground.color
|
||||
|
||||
checkbox.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(checkbox)
|
||||
NSLayoutConstraint.activate([
|
||||
checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 1),
|
||||
checkbox.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
checkbox.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1),
|
||||
])
|
||||
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerView)
|
||||
containerView.addSubview(domainLabel)
|
||||
containerView.addSubview(checkbox)
|
||||
containerView.addSubview(descriptionLabel)
|
||||
containerView.addSubview(separator)
|
||||
|
||||
containerView.addSubview(expandButton)
|
||||
|
||||
// Always add the expandbox which contains elements only visible in expand mode
|
||||
containerView.addSubview(expandBox)
|
||||
expandBox.addSubview(thumbnailImageView)
|
||||
expandBox.addSubview(infoStackView)
|
||||
expandBox.isHidden = true
|
||||
|
||||
let verticalInfoStackViewLang = makeVerticalInfoStackView(arrangedView: langValueLabel, langTitleLabel)
|
||||
let verticalInfoStackViewUsers = makeVerticalInfoStackView(arrangedView: usersValueLabel, usersTitleLabel)
|
||||
let verticalInfoStackViewCategory = makeVerticalInfoStackView(arrangedView: categoryValueLabel, categoryTitleLabel)
|
||||
infoStackView.addArrangedSubview(verticalInfoStackViewLang)
|
||||
infoStackView.addArrangedSubview(verticalInfoStackViewUsers)
|
||||
infoStackView.addArrangedSubview(verticalInfoStackViewCategory)
|
||||
|
||||
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1)
|
||||
collapseConstraints.append(expandButtonTopConstraintInCollapse)
|
||||
|
||||
let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh)
|
||||
expandConstraints.append(expandButtonTopConstraintInExpand)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// Set background view
|
||||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
|
||||
// Set bottom separator
|
||||
separator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: separator.trailingAnchor),
|
||||
containerView.topAnchor.constraint(equalTo: separator.topAnchor),
|
||||
separator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh),
|
||||
|
||||
domainLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor),
|
||||
domainLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
|
||||
|
||||
checkbox.widthAnchor.constraint(equalToConstant: 23),
|
||||
checkbox.heightAnchor.constraint(equalToConstant: 22),
|
||||
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor),
|
||||
checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16),
|
||||
checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor),
|
||||
|
||||
descriptionLabel.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
|
||||
descriptionLabel.topAnchor.constraint(equalTo: domainLabel.bottomAnchor, constant: 8),
|
||||
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: descriptionLabel.trailingAnchor),
|
||||
|
||||
// Set expandBox constraints
|
||||
expandBox.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
|
||||
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandBox.trailingAnchor),
|
||||
expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
|
||||
expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh),
|
||||
|
||||
thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
|
||||
thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||
expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor),
|
||||
thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
|
||||
|
||||
infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||
expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor),
|
||||
infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16),
|
||||
|
||||
expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
|
||||
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor),
|
||||
containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor),
|
||||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11),
|
||||
containerView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 22),
|
||||
containerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 11),
|
||||
checkbox.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
])
|
||||
|
||||
thumbnailActivityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
thumbnailImageView.addSubview(thumbnailActivityIndicator)
|
||||
containerView.addArrangedSubview(domainLabel)
|
||||
containerView.addArrangedSubview(descriptionLabel)
|
||||
containerView.setCustomSpacing(6, after: descriptionLabel)
|
||||
containerView.addArrangedSubview(infoStackView)
|
||||
|
||||
infoStackView.addArrangedSubview(usersValueLabel)
|
||||
infoStackView.addArrangedSubview(langValueLabel)
|
||||
infoStackView.addArrangedSubview(UIView())
|
||||
|
||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(separator)
|
||||
NSLayoutConstraint.activate([
|
||||
thumbnailActivityIndicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor),
|
||||
thumbnailActivityIndicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor),
|
||||
separator.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: separator.trailingAnchor),
|
||||
separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
separator.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)).priority(.required - 1),
|
||||
])
|
||||
thumbnailActivityIndicator.hidesWhenStopped = true
|
||||
thumbnailActivityIndicator.stopAnimating()
|
||||
|
||||
NSLayoutConstraint.activate(collapseConstraints)
|
||||
|
||||
domainLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
domainLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||
descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical)
|
||||
descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
|
||||
expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
configureMargin()
|
||||
}
|
||||
|
||||
private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView {
|
||||
let stackView = UIStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .equalCentering
|
||||
stackView.spacing = 2
|
||||
arrangedView.forEach { stackView.addArrangedSubview($0) }
|
||||
return stackView
|
||||
}
|
||||
|
||||
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||
super.setSelected(selected, animated: animated)
|
||||
if selected {
|
||||
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
|
||||
checkbox.tintColor = Asset.Colors.Label.primary.color
|
||||
} else {
|
||||
checkbox.image = UIImage(systemName: "circle")
|
||||
checkbox.tintColor = Asset.Colors.Label.secondary.color
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func expandButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.pickServerCell(self, expandButtonPressed: sender)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PickServerCell {
|
||||
private func configureMargin() {
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
default:
|
||||
contentView.layoutMargins = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerCell {
|
||||
|
||||
enum ExpandMode {
|
||||
case collapse
|
||||
case expand
|
||||
}
|
||||
|
||||
func updateExpandMode(mode: ExpandMode) {
|
||||
switch mode {
|
||||
case .collapse:
|
||||
expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
|
||||
expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
|
||||
expandBox.isHidden = true
|
||||
expandButton.isSelected = false
|
||||
NSLayoutConstraint.deactivate(expandConstraints)
|
||||
NSLayoutConstraint.activate(collapseConstraints)
|
||||
case .expand:
|
||||
expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal)
|
||||
expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal)
|
||||
expandBox.isHidden = false
|
||||
expandButton.isSelected = true
|
||||
NSLayoutConstraint.activate(expandConstraints)
|
||||
NSLayoutConstraint.deactivate(collapseConstraints)
|
||||
}
|
||||
|
||||
expandMode.value = mode
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -13,15 +13,7 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
|
|||
let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
|
||||
view.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let seperator: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Theme.Mastodon.systemGroupedBackground.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}()
|
||||
|
||||
|
@ -30,30 +22,22 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
|
|||
label.text = L10n.Scene.ServerPicker.EmptyState.noResults
|
||||
label.textColor = Asset.Colors.Label.secondary.color
|
||||
label.textAlignment = .center
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19)
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold))
|
||||
return label
|
||||
}()
|
||||
|
||||
override func _init() {
|
||||
super._init()
|
||||
|
||||
configureMargin()
|
||||
|
||||
contentView.addSubview(containerView)
|
||||
contentView.addSubview(seperator)
|
||||
|
||||
|
||||
// Set background view
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerView)
|
||||
NSLayoutConstraint.activate([
|
||||
// Set background view
|
||||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1),
|
||||
|
||||
// Set bottom separator
|
||||
seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor),
|
||||
containerView.topAnchor.constraint(equalTo: seperator.topAnchor),
|
||||
seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh),
|
||||
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
])
|
||||
|
||||
emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -69,24 +53,7 @@ final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell {
|
|||
activityIndicatorView.isHidden = false
|
||||
startAnimating()
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
configureMargin()
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerLoaderTableViewCell {
|
||||
private func configureMargin() {
|
||||
switch traitCollection.horizontalSizeClass {
|
||||
case .regular:
|
||||
let margin = MastodonPickServerViewController.viewEdgeMargin
|
||||
contentView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
|
||||
default:
|
||||
contentView.layoutMargins = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
|
|