diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7e2abbc65..01b40a9c1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -1719,6 +1721,7 @@ children = ( DBB525732612D5A5002F1F29 /* View */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, ); path = Header; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 6ee852bd9..7e2177348 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -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") } } diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index b8c5285a2..1c2c78da3 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -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, diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..aa5323a21 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json @@ -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 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..b4ce9fd5b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json @@ -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 + } +} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 5c5f77600..b6ba6a8af 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -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 diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 6439ea42b..2187ad52b 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -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() 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, diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 4412950ac..8f382336e 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -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() weak var delegate: ProfileHeaderViewControllerDelegate? - var disposeBag = Set() + 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(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) + } +} + diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift new file mode 100644 index 000000000..be0676740 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -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() + + // input + let context: AppContext + let isEditing = CurrentValueSubject(false) + let viewDidAppear = PassthroughSubject() + let needsSetupBottomShadow = CurrentValueSubject(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(nil) + let avatarImageResource = CurrentValueSubject(nil) + let note = CurrentValueSubject(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, 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 + ) + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index bf292ac45..2fba55e66 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -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) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index 70e6e1647..d4b57ffe4 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -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 } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index f070f72c6..75bd04dc9 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,6 +18,12 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() 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) { diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 7db38d33c..445952e96 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -43,8 +43,10 @@ class ProfileViewModel: NSObject { let protected: CurrentValueSubject let suspended: CurrentValueSubject - let relationshipActionOptionSet = CurrentValueSubject(.none) let isEditing = CurrentValueSubject(false) + let isUpdating = CurrentValueSubject(false) + + let relationshipActionOptionSet = CurrentValueSubject(.none) let isFollowedBy = CurrentValueSubject(false) let isMuting = CurrentValueSubject(false) let isBlocking = CurrentValueSubject(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 } }