feat: implement profile infos editing
This commit is contained in:
parent
2ce5c4db6b
commit
4faacdf1be
|
@ -224,6 +224,7 @@
|
|||
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; };
|
||||
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
|
||||
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; };
|
||||
DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; };
|
||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; };
|
||||
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; };
|
||||
|
@ -600,6 +601,7 @@
|
|||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; };
|
||||
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
|
||||
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
|
||||
|
@ -1719,6 +1721,7 @@
|
|||
children = (
|
||||
DBB525732612D5A5002F1F29 /* View */,
|
||||
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
|
||||
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
|
||||
);
|
||||
path = Header;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2250,6 +2253,7 @@
|
|||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */,
|
||||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */,
|
||||
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
|
||||
|
|
|
@ -86,6 +86,8 @@ internal enum Asset {
|
|||
}
|
||||
internal enum Profile {
|
||||
internal enum Banner {
|
||||
internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray")
|
||||
internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray")
|
||||
internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ extension AvatarConfigurableView {
|
|||
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
|
||||
return placeholderImage
|
||||
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
|
||||
.af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true)
|
||||
.af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true)
|
||||
} else {
|
||||
return placeholderImage.af.imageRoundedIntoCircle()
|
||||
}
|
||||
|
@ -50,11 +50,20 @@ extension AvatarConfigurableView {
|
|||
defer {
|
||||
avatarConfigurableView(self, didFinishConfiguration: configuration)
|
||||
}
|
||||
|
||||
|
||||
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
|
||||
|
||||
// set placeholder if no asset
|
||||
guard let avatarImageURL = configuration.avatarImageURL else {
|
||||
configurableAvatarImageView?.image = placeholderImage
|
||||
configurableAvatarImageView?.layer.masksToBounds = true
|
||||
configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
|
||||
configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
|
||||
|
||||
configurableAvatarButton?.setImage(placeholderImage, for: .normal)
|
||||
configurableAvatarButton?.layer.masksToBounds = true
|
||||
configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
|
||||
configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -74,7 +83,6 @@ extension AvatarConfigurableView {
|
|||
avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
|
||||
|
||||
default:
|
||||
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
|
||||
avatarImageView.af.setImage(
|
||||
withURL: avatarImageURL,
|
||||
placeholderImage: placeholderImage,
|
||||
|
@ -103,7 +111,6 @@ extension AvatarConfigurableView {
|
|||
avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
|
||||
avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
|
||||
default:
|
||||
let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius)
|
||||
avatarButton.af.setImage(
|
||||
for: .normal,
|
||||
url: avatarImageURL,
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.200",
|
||||
"blue" : "128",
|
||||
"green" : "120",
|
||||
"red" : "120"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.360",
|
||||
"blue" : "128",
|
||||
"green" : "120",
|
||||
"red" : "120"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "0.360",
|
||||
"blue" : "128",
|
||||
"green" : "120",
|
||||
"red" : "120"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -42,7 +42,7 @@ extension MastodonRegisterViewController {
|
|||
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
|
||||
}
|
||||
|
||||
private func cropImage(image:UIImage,pickerViewController:UIViewController) {
|
||||
private func cropImage(image: UIImage, pickerViewController: UIViewController) {
|
||||
DispatchQueue.main.async {
|
||||
let cropController = CropViewController(croppingStyle: .default, image: image)
|
||||
cropController.delegate = self
|
||||
|
|
|
@ -13,6 +13,9 @@ import PhotosUI
|
|||
import UIKit
|
||||
|
||||
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
|
||||
|
||||
static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400)
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
|
@ -684,10 +687,10 @@ extension MastodonRegisterViewController {
|
|||
let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value
|
||||
let avatar: Mastodon.Query.MediaAttachment? = {
|
||||
guard let avatarImage = self.viewModel.avatarImage.value else { return nil }
|
||||
guard avatarImage.size.width <= 400 else {
|
||||
return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8))
|
||||
guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
|
||||
return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData())
|
||||
}
|
||||
return .jpeg(avatarImage.jpegData(compressionQuality: 0.8))
|
||||
return .png(avatarImage.pngData())
|
||||
}()
|
||||
return Mastodon.API.Account.UpdateCredentialQuery(
|
||||
displayName: displayName,
|
||||
|
|
|
@ -8,6 +8,10 @@
|
|||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import PhotosUI
|
||||
import AlamofireImage
|
||||
import CropViewController
|
||||
import TwitterTextEditor
|
||||
|
||||
protocol ProfileHeaderViewControllerDelegate: class {
|
||||
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
|
||||
|
@ -20,9 +24,10 @@ final class ProfileHeaderViewController: UIViewController {
|
|||
static let segmentedControlMarginHeight: CGFloat = 20
|
||||
static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
weak var delegate: ProfileHeaderViewControllerDelegate?
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ProfileHeaderViewModel!
|
||||
|
||||
let profileHeaderView = ProfileHeaderView()
|
||||
let pageSegmentedControl: UISegmentedControl = {
|
||||
|
@ -37,7 +42,27 @@ final class ProfileHeaderViewController: UIViewController {
|
|||
// private var isAdjustBannerImageViewForSafeAreaInset = false
|
||||
private var containerSafeAreaInset: UIEdgeInsets = .zero
|
||||
|
||||
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||
private(set) lazy var imagePicker: PHPickerViewController = {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
|
||||
let imagePicker = PHPickerViewController(configuration: configuration)
|
||||
imagePicker.delegate = self
|
||||
return imagePicker
|
||||
}()
|
||||
private(set) lazy var imagePickerController: UIImagePickerController = {
|
||||
let imagePickerController = UIImagePickerController()
|
||||
imagePickerController.sourceType = .camera
|
||||
imagePickerController.delegate = self
|
||||
return imagePickerController
|
||||
}()
|
||||
|
||||
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
|
||||
documentPickerController.delegate = self
|
||||
return documentPickerController
|
||||
}()
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
@ -73,18 +98,86 @@ extension ProfileHeaderViewController {
|
|||
|
||||
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged)
|
||||
|
||||
needsSetupBottomShadow
|
||||
viewModel.needsSetupBottomShadow
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] needsSetupBottomShadow in
|
||||
guard let self = self else { return }
|
||||
self.setupBottomShadow()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest4(
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(),
|
||||
viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(),
|
||||
viewModel.viewDidAppear.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing, resource, editingResource, _ in
|
||||
guard let self = self else { return }
|
||||
let url: URL? = {
|
||||
guard case let .url(url) = resource else { return nil }
|
||||
return url
|
||||
|
||||
}()
|
||||
let image: UIImage? = {
|
||||
guard case let .image(image) = editingResource else { return nil }
|
||||
return image
|
||||
}()
|
||||
self.profileHeaderView.configure(
|
||||
with: AvatarConfigurableViewConfiguration(
|
||||
avatarImageURL: image == nil ? url : nil, // set only when image empty
|
||||
placeholderImage: image,
|
||||
borderColor: .white,
|
||||
borderWidth: 2
|
||||
)
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(),
|
||||
viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing, name, editingName in
|
||||
guard let self = self else { return }
|
||||
self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(),
|
||||
viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing, note, editingNote in
|
||||
guard let self = self else { return }
|
||||
self.profileHeaderView.bioActiveLabel.configure(note: note ?? "")
|
||||
self.profileHeaderView.bioTextEditorView.text = editingNote ?? ""
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
profileHeaderView.bioTextEditorView.changeObserver = self
|
||||
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
guard let self = self else { return }
|
||||
guard let textField = notification.object as? UITextField else { return }
|
||||
self.viewModel.editProfileInfo.name.value = textField.text
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
|
||||
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppear.send()
|
||||
|
||||
// Deprecated:
|
||||
// not needs this tweak due to force layout update in the parent
|
||||
// if !isAdjustBannerImageViewForSafeAreaInset {
|
||||
|
@ -103,6 +196,47 @@ extension ProfileHeaderViewController {
|
|||
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewController {
|
||||
private func createAvatarContextMenu() -> UIMenu {
|
||||
var children: [UIMenuElement] = []
|
||||
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
self.present(self.imagePicker, animated: true, completion: nil)
|
||||
}
|
||||
children.append(photoLibraryAction)
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
self.present(self.imagePickerController, animated: true, completion: nil)
|
||||
})
|
||||
children.append(cameraAction)
|
||||
}
|
||||
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
self.present(self.documentPickerController, animated: true, completion: nil)
|
||||
}
|
||||
children.append(browseAction)
|
||||
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
|
||||
}
|
||||
|
||||
private func cropImage(image: UIImage, pickerViewController: UIViewController) {
|
||||
DispatchQueue.main.async {
|
||||
let cropController = CropViewController(croppingStyle: .default, image: image)
|
||||
cropController.delegate = self
|
||||
cropController.setAspectRatioPreset(.presetSquare, animated: true)
|
||||
cropController.aspectRatioPickerButtonHidden = true
|
||||
cropController.aspectRatioLockEnabled = true
|
||||
pickerViewController.dismiss(animated: true, completion: {
|
||||
self.present(cropController, animated: true, completion: nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewController {
|
||||
|
||||
@objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) {
|
||||
|
@ -119,7 +253,7 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
|
||||
func setupBottomShadow() {
|
||||
guard needsSetupBottomShadow.value else {
|
||||
guard viewModel.needsSetupBottomShadow.value else {
|
||||
view.layer.shadowColor = nil
|
||||
view.layer.shadowRadius = 0
|
||||
return
|
||||
|
@ -164,3 +298,80 @@ extension ProfileHeaderViewController {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TextEditorViewChangeObserver
|
||||
extension ProfileHeaderViewController: TextEditorViewChangeObserver {
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
|
||||
guard changeResult.isTextChanged else { return }
|
||||
assert(textEditorView === profileHeaderView.bioTextEditorView)
|
||||
viewModel.editProfileInfo.note.value = textEditorView.text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PHPickerViewControllerDelegate
|
||||
extension ProfileHeaderViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
guard let result = results.first else { return }
|
||||
PHPickerResultLoader.loadImageData(from: result)
|
||||
.sink { [weak self] completion in
|
||||
guard let _ = self else { return }
|
||||
switch completion {
|
||||
case .failure:
|
||||
// TODO: handle error
|
||||
break
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] imageData in
|
||||
guard let self = self else { return }
|
||||
guard let imageData = imageData else { return }
|
||||
guard let image = UIImage(data: imageData) else { return }
|
||||
self.cropImage(image: image, pickerViewController: picker)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIImagePickerControllerDelegate
|
||||
extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate {
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
|
||||
guard let image = info[.originalImage] as? UIImage else { return }
|
||||
cropImage(image: image, pickerViewController: picker)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
extension ProfileHeaderViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
do {
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
guard let image = UIImage(data: imageData) else { return }
|
||||
cropImage(image: image, pickerViewController: controller)
|
||||
} catch {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropViewControllerDelegate
|
||||
extension ProfileHeaderViewController: CropViewControllerDelegate {
|
||||
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
|
||||
viewModel.editProfileInfo.avatarImageResource.value = .image(image)
|
||||
cropViewController.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
//
|
||||
// ProfileHeaderViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-9.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import Kanna
|
||||
import MastodonSDK
|
||||
|
||||
final class ProfileHeaderViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
|
||||
|
||||
// output
|
||||
let displayProfileInfo = ProfileInfo()
|
||||
let editProfileInfo = ProfileInfo()
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
|
||||
isEditing
|
||||
.removeDuplicates() // only triiger when value toggle
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing in
|
||||
guard let self = self else { return }
|
||||
// setup editing value when toggle to editing
|
||||
self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name
|
||||
self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty
|
||||
self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewModel {
|
||||
struct ProfileInfo {
|
||||
let name = CurrentValueSubject<String?, Never>(nil)
|
||||
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
|
||||
let note = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
enum ImageResource {
|
||||
case url(URL?)
|
||||
case image(UIImage?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderViewModel {
|
||||
|
||||
static func normalize(note: String?) -> String? {
|
||||
guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let html = try? HTML(html: note, encoding: .utf8)
|
||||
return html?.text
|
||||
}
|
||||
|
||||
// check if profile chagned or not
|
||||
func isProfileInfoEdited() -> Bool {
|
||||
guard isEditing.value else { return false }
|
||||
|
||||
guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true }
|
||||
guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true }
|
||||
guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func updateProfileInfo() -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher()
|
||||
}
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
let authorization = activeMastodonAuthenticationBox.userAuthorization
|
||||
|
||||
let image: UIImage? = {
|
||||
guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil }
|
||||
guard let image = _image else { return nil }
|
||||
guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else {
|
||||
return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel)
|
||||
}
|
||||
return image
|
||||
}()
|
||||
|
||||
let query = Mastodon.API.Account.UpdateCredentialQuery(
|
||||
discoverable: nil,
|
||||
bot: nil,
|
||||
displayName: editProfileInfo.name.value,
|
||||
note: editProfileInfo.note.value,
|
||||
avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
|
||||
header: nil,
|
||||
locked: nil,
|
||||
source: nil,
|
||||
fieldsAttributes: nil // TODO:
|
||||
)
|
||||
return context.apiService.accountUpdateCredentials(
|
||||
domain: domain,
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -8,6 +8,7 @@
|
|||
import os.log
|
||||
import UIKit
|
||||
import ActiveLabel
|
||||
import TwitterTextEditor
|
||||
|
||||
protocol ProfileHeaderViewDelegate: class {
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
|
||||
|
@ -25,8 +26,13 @@ final class ProfileHeaderView: UIView {
|
|||
static let friendshipActionButtonSize = CGSize(width: 108, height: 34)
|
||||
static let bannerImageViewPlaceholderColor = UIColor.systemGray
|
||||
|
||||
static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5)
|
||||
static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8)
|
||||
|
||||
weak var delegate: ProfileHeaderViewDelegate?
|
||||
|
||||
var state: State?
|
||||
|
||||
let bannerContainerView = UIView()
|
||||
let bannerImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
|
@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView {
|
|||
}()
|
||||
let bannerImageViewOverlayView: UIView = {
|
||||
let overlayView = UIView()
|
||||
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
|
||||
return overlayView
|
||||
}()
|
||||
|
||||
|
@ -53,16 +59,40 @@ final class ProfileHeaderView: UIView {
|
|||
imageView.image = placeholderImage
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let editAvatarBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
view.layer.masksToBounds = true
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
|
||||
return view
|
||||
}()
|
||||
|
||||
let editAvatarButton: HighlightDimmableButton = {
|
||||
let button = HighlightDimmableButton()
|
||||
button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
|
||||
button.tintColor = .white
|
||||
return button
|
||||
}()
|
||||
|
||||
let nameLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.5
|
||||
label.textColor = .white
|
||||
label.text = "Alice"
|
||||
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
|
||||
return label
|
||||
let nameTextFieldBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.masksToBounds = true
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.layer.cornerRadius = 10
|
||||
return view
|
||||
}()
|
||||
|
||||
let nameTextField: UITextField = {
|
||||
let textField = UITextField()
|
||||
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
textField.textColor = .white
|
||||
textField.text = "Alice"
|
||||
textField.autocorrectionType = .no
|
||||
textField.autocapitalizationType = .none
|
||||
textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
|
||||
return textField
|
||||
}()
|
||||
|
||||
let usernameLabel: UILabel = {
|
||||
|
@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView {
|
|||
}()
|
||||
|
||||
let bioContainerView = UIView()
|
||||
let bioContainerStackView = UIStackView()
|
||||
let fieldContainerStackView = UIStackView()
|
||||
|
||||
let bioActiveLabelContainer: UIView = {
|
||||
// use to set margin for active label
|
||||
// the display/edit mode bio transition animation should without flicker with that
|
||||
let view = UIView()
|
||||
// note: comment out to see how it works
|
||||
view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView
|
||||
return view
|
||||
}()
|
||||
let bioActiveLabel = ActiveLabel(style: .default)
|
||||
let bioTextEditorView: TextEditorView = {
|
||||
let textEditorView = TextEditorView()
|
||||
textEditorView.scrollView.isScrollEnabled = false
|
||||
textEditorView.isScrollEnabled = false
|
||||
textEditorView.font = .preferredFont(forTextStyle: .body)
|
||||
textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
|
||||
textEditorView.layer.masksToBounds = true
|
||||
textEditorView.layer.cornerCurve = .continuous
|
||||
textEditorView.layer.cornerRadius = 10
|
||||
return textEditorView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -137,12 +187,32 @@ extension ProfileHeaderView {
|
|||
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
|
||||
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
|
||||
])
|
||||
|
||||
editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarImageView.addSubview(editAvatarBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
|
||||
editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
|
||||
editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
|
||||
editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
|
||||
])
|
||||
|
||||
editAvatarButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
editAvatarBackgroundView.addSubview(editAvatarButton)
|
||||
NSLayoutConstraint.activate([
|
||||
editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
|
||||
editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
|
||||
editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
|
||||
editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
|
||||
])
|
||||
editAvatarBackgroundView.isUserInteractionEnabled = true
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
|
||||
// name container: [display name | username]
|
||||
// name container: [display name container | username]
|
||||
let nameContainerStackView = UIStackView()
|
||||
nameContainerStackView.preservesSuperviewLayoutMargins = true
|
||||
nameContainerStackView.axis = .vertical
|
||||
nameContainerStackView.spacing = 0
|
||||
nameContainerStackView.spacing = 7
|
||||
nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(nameContainerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -150,7 +220,27 @@ extension ProfileHeaderView {
|
|||
nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
|
||||
nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
|
||||
])
|
||||
nameContainerStackView.addArrangedSubview(nameLabel)
|
||||
|
||||
let displayNameStackView = UIStackView()
|
||||
displayNameStackView.axis = .horizontal
|
||||
nameTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
displayNameStackView.addArrangedSubview(nameTextField)
|
||||
NSLayoutConstraint.activate([
|
||||
nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
displayNameStackView.addSubview(nameTextFieldBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5),
|
||||
nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5),
|
||||
nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5),
|
||||
nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5),
|
||||
])
|
||||
displayNameStackView.bringSubviewToFront(nameTextField)
|
||||
displayNameStackView.addArrangedSubview(UIView())
|
||||
|
||||
nameContainerStackView.addArrangedSubview(displayNameStackView)
|
||||
nameContainerStackView.addArrangedSubview(usernameLabel)
|
||||
|
||||
// meta container: [dashboard container | bio container | field container]
|
||||
|
@ -192,15 +282,29 @@ extension ProfileHeaderView {
|
|||
|
||||
bioContainerView.preservesSuperviewLayoutMargins = true
|
||||
metaContainerStackView.addArrangedSubview(bioContainerView)
|
||||
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
bioContainerView.addSubview(bioActiveLabel)
|
||||
|
||||
bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bioContainerView.addSubview(bioContainerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
|
||||
bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
|
||||
bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
|
||||
bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
|
||||
bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor),
|
||||
bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor),
|
||||
bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor),
|
||||
bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor),
|
||||
])
|
||||
|
||||
bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
bioActiveLabelContainer.addSubview(bioActiveLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor),
|
||||
bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor),
|
||||
bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor),
|
||||
bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor),
|
||||
])
|
||||
|
||||
bioContainerStackView.axis = .vertical
|
||||
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
|
||||
bioContainerStackView.addArrangedSubview(bioTextEditorView)
|
||||
|
||||
fieldContainerStackView.preservesSuperviewLayoutMargins = true
|
||||
metaContainerStackView.addSubview(fieldContainerStackView)
|
||||
|
||||
|
@ -210,10 +314,58 @@ extension ProfileHeaderView {
|
|||
bioActiveLabel.delegate = self
|
||||
|
||||
relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
|
||||
|
||||
configure(state: .normal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileHeaderView {
|
||||
enum State {
|
||||
case normal
|
||||
case editing
|
||||
}
|
||||
|
||||
func configure(state: State) {
|
||||
guard self.state != state else { return } // avoid redundant animation
|
||||
self.state = state
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
nameTextField.isEnabled = false
|
||||
bioActiveLabelContainer.isHidden = false
|
||||
bioTextEditorView.isHidden = true
|
||||
|
||||
animator.addAnimations {
|
||||
self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
|
||||
self.nameTextFieldBackgroundView.backgroundColor = .clear
|
||||
self.editAvatarBackgroundView.alpha = 0
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
self.editAvatarBackgroundView.isHidden = true
|
||||
}
|
||||
case .editing:
|
||||
nameTextField.isEnabled = true
|
||||
bioActiveLabelContainer.isHidden = true
|
||||
bioTextEditorView.isHidden = false
|
||||
|
||||
editAvatarBackgroundView.isHidden = false
|
||||
editAvatarBackgroundView.alpha = 0
|
||||
bioTextEditorView.backgroundColor = .clear
|
||||
animator.addAnimations {
|
||||
self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
|
||||
self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color
|
||||
self.editAvatarBackgroundView.alpha = 1
|
||||
self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
|
||||
}
|
||||
}
|
||||
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileHeaderView {
|
||||
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
|
|
@ -9,6 +9,12 @@ import UIKit
|
|||
|
||||
final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
||||
|
||||
let actvityIndicatorView: UIActivityIndicatorView = {
|
||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicatorView.color = .white
|
||||
return activityIndicatorView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
|
@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
|||
|
||||
extension ProfileRelationshipActionButton {
|
||||
private func _init() {
|
||||
// do nothing
|
||||
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(actvityIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
|
||||
actvityIndicatorView.hidesWhenStopped = true
|
||||
actvityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,8 +50,13 @@ extension ProfileRelationshipActionButton {
|
|||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
||||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
|
||||
|
||||
actvityIndicatorView.stopAnimating()
|
||||
|
||||
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
|
||||
isEnabled = false
|
||||
} else if actionOptionSet.contains(.updating) {
|
||||
isEnabled = false
|
||||
actvityIndicatorView.startAnimating()
|
||||
} else {
|
||||
isEnabled = true
|
||||
}
|
||||
|
|
|
@ -18,6 +18,12 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ProfileViewModel!
|
||||
|
||||
private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
return barButtonItem
|
||||
}()
|
||||
|
||||
private(set) lazy var settingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:)))
|
||||
barButtonItem.tintColor = .white
|
||||
|
@ -72,7 +78,11 @@ final class ProfileViewController: UIViewController, NeedsDependency {
|
|||
}()
|
||||
|
||||
private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController()
|
||||
private(set) lazy var profileHeaderViewController = ProfileHeaderViewController()
|
||||
private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = {
|
||||
let viewController = ProfileHeaderViewController()
|
||||
viewController.viewModel = ProfileHeaderViewModel(context: context)
|
||||
return viewController
|
||||
}()
|
||||
private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
private var contentOffsets: [Int: CGFloat] = [:]
|
||||
|
@ -136,15 +146,38 @@ extension ProfileViewController {
|
|||
|
||||
navigationItem.titleView = UIView()
|
||||
|
||||
Publishers.CombineLatest4(
|
||||
viewModel.suspended.eraseToAnyPublisher(),
|
||||
let editingAndUpdatingPublisher = Publishers.CombineLatest(
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.isUpdating.eraseToAnyPublisher()
|
||||
)
|
||||
.share()
|
||||
|
||||
editingAndUpdatingPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing, isUpdating in
|
||||
guard let self = self else { return }
|
||||
self.cancelEditingBarButtonItem.isEnabled = !isUpdating
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let barButtonItemHiddenPublisher = Publishers.CombineLatest3(
|
||||
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
|
||||
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
|
||||
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
|
||||
)
|
||||
.share()
|
||||
|
||||
Publishers.CombineLatest3 (
|
||||
viewModel.suspended.eraseToAnyPublisher(),
|
||||
editingAndUpdatingPublisher.eraseToAnyPublisher(),
|
||||
barButtonItemHiddenPublisher.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] suspended, isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
|
||||
.sink { [weak self] suspended, tuple1, tuple2 in
|
||||
guard let self = self else { return }
|
||||
let (isEditing, _) = tuple1
|
||||
let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2
|
||||
|
||||
var items: [UIBarButtonItem] = []
|
||||
defer {
|
||||
self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil
|
||||
|
@ -154,6 +187,11 @@ extension ProfileViewController {
|
|||
return
|
||||
}
|
||||
|
||||
guard !isEditing else {
|
||||
items.append(self.cancelEditingBarButtonItem)
|
||||
return
|
||||
}
|
||||
|
||||
guard isMeBarButtonItemsHidden else {
|
||||
items.append(self.settingBarButtonItem)
|
||||
items.append(self.shareBarButtonItem)
|
||||
|
@ -293,22 +331,15 @@ extension ProfileViewController {
|
|||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
Publishers.CombineLatest(
|
||||
viewModel.avatarImageURL.eraseToAnyPublisher(),
|
||||
viewModel.viewDidAppear.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] avatarImageURL, _ in
|
||||
guard let self = self else { return }
|
||||
self.profileHeaderViewController.profileHeaderView.configure(
|
||||
with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2)
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.name
|
||||
.map { $0 ?? " " }
|
||||
viewModel.avatarImageURL
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel)
|
||||
.map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) }
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.name
|
||||
.map { $0 ?? "" }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.username
|
||||
.map { username in username.flatMap { "@" + $0 } ?? " " }
|
||||
|
@ -336,21 +367,41 @@ extension ProfileViewController {
|
|||
self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
Publishers.CombineLatest(
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.relationshipActionOptionSet.eraseToAnyPublisher(),
|
||||
viewModel.isEditing.eraseToAnyPublisher()
|
||||
viewModel.isEditing.eraseToAnyPublisher(),
|
||||
viewModel.isUpdating.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] relationshipActionSet, isEditing in
|
||||
.sink { [weak self] relationshipActionSet, isEditing, isUpdating in
|
||||
guard let self = self else { return }
|
||||
let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton
|
||||
if relationshipActionSet.contains(.edit) {
|
||||
friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit)
|
||||
// check .edit state and set .editing when isEditing
|
||||
friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit))
|
||||
self.profileHeaderViewController.profileHeaderView.configure(state: isUpdating || isEditing ? .editing : .normal)
|
||||
} else {
|
||||
friendshipButton.configure(actionOptionSet: relationshipActionSet)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.isEditing
|
||||
.handleEvents(receiveOutput: { [weak self] isEditing in
|
||||
guard let self = self else { return }
|
||||
// dismiss keyboard if needs
|
||||
if !isEditing { self.view.endEditing(true) }
|
||||
|
||||
self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing
|
||||
self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0
|
||||
}
|
||||
animator.startAnimation()
|
||||
})
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing)
|
||||
.store(in: &disposeBag)
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.isBlocking.eraseToAnyPublisher(),
|
||||
viewModel.isBlockedBy.eraseToAnyPublisher(),
|
||||
|
@ -360,7 +411,7 @@ extension ProfileViewController {
|
|||
.sink { [weak self] isBlocking, isBlockedBy, suspended in
|
||||
guard let self = self else { return }
|
||||
let isNeedSetHidden = isBlocking || isBlockedBy || suspended
|
||||
self.profileHeaderViewController.needsSetupBottomShadow.value = !isNeedSetHidden
|
||||
self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden
|
||||
self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden
|
||||
self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden
|
||||
self.viewModel.needsPagePinToTop.value = isNeedSetHidden
|
||||
|
@ -368,10 +419,7 @@ extension ProfileViewController {
|
|||
.store(in: &disposeBag)
|
||||
viewModel.bioDescription
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] bio in
|
||||
guard let self = self else { return }
|
||||
self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "")
|
||||
})
|
||||
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note)
|
||||
.store(in: &disposeBag)
|
||||
viewModel.statusesCount
|
||||
.sink { [weak self] count in
|
||||
|
@ -420,6 +468,7 @@ extension ProfileViewController {
|
|||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
currentPostTimelineTableViewContentSizeObservation = nil
|
||||
}
|
||||
|
||||
|
@ -440,6 +489,11 @@ extension ProfileViewController {
|
|||
|
||||
extension ProfileViewController {
|
||||
|
||||
@objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
viewModel.isEditing.value = false
|
||||
}
|
||||
|
||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
|
@ -488,11 +542,6 @@ extension ProfileViewController {
|
|||
sender.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
// @objc private func avatarButtonPressed(_ sender: UIButton) {
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController))
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
|
@ -571,7 +620,29 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) {
|
||||
let relationshipActionSet = viewModel.relationshipActionOptionSet.value
|
||||
if relationshipActionSet.contains(.edit) {
|
||||
viewModel.isEditing.value.toggle()
|
||||
guard !viewModel.isUpdating.value else { return }
|
||||
|
||||
if profileHeaderViewController.viewModel.isProfileInfoEdited() {
|
||||
viewModel.isUpdating.value = true
|
||||
profileHeaderViewController.viewModel.updateProfileInfo()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
self.viewModel.isUpdating.value = false
|
||||
} receiveValue: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.isEditing.value = false
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
} else {
|
||||
viewModel.isEditing.value.toggle()
|
||||
}
|
||||
} else {
|
||||
guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return }
|
||||
switch relationshipAction {
|
||||
|
@ -634,9 +705,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
|
|||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) {
|
||||
|
|
|
@ -43,8 +43,10 @@ class ProfileViewModel: NSObject {
|
|||
let protected: CurrentValueSubject<Bool?, Never>
|
||||
let suspended: CurrentValueSubject<Bool, Never>
|
||||
|
||||
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
|
||||
let isEditing = CurrentValueSubject<Bool, Never>(false)
|
||||
let isUpdating = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
|
||||
let isFollowedBy = CurrentValueSubject<Bool, Never>(false)
|
||||
let isMuting = CurrentValueSubject<Bool, Never>(false)
|
||||
let isBlocking = CurrentValueSubject<Bool, Never>(false)
|
||||
|
@ -328,6 +330,7 @@ extension ProfileViewModel {
|
|||
case suspended
|
||||
case edit
|
||||
case editing
|
||||
case updating
|
||||
|
||||
var option: RelationshipActionOptionSet {
|
||||
return RelationshipActionOptionSet(rawValue: 1 << rawValue)
|
||||
|
@ -349,8 +352,9 @@ extension ProfileViewModel {
|
|||
static let suspended = RelationshipAction.suspended.option
|
||||
static let edit = RelationshipAction.edit.option
|
||||
static let editing = RelationshipAction.editing.option
|
||||
static let updating = RelationshipAction.updating.option
|
||||
|
||||
static let editOptions: RelationshipActionOptionSet = [.edit, .editing]
|
||||
static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating]
|
||||
|
||||
func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? {
|
||||
let set = subtracting(except)
|
||||
|
@ -378,6 +382,7 @@ extension ProfileViewModel {
|
|||
case .suspended: return L10n.Common.Controls.Firendship.follow
|
||||
case .edit: return L10n.Common.Controls.Firendship.editInfo
|
||||
case .editing: return L10n.Common.Controls.Actions.done
|
||||
case .updating: return " "
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -398,6 +403,7 @@ extension ProfileViewModel {
|
|||
case .suspended: return Asset.Colors.Button.normal.color
|
||||
case .edit: return Asset.Colors.Button.normal.color
|
||||
case .editing: return Asset.Colors.Button.normal.color
|
||||
case .updating: return Asset.Colors.Button.normal.color
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue