feat: implement profile infos editing

This commit is contained in:
CMK 2021-04-09 17:31:43 +08:00
parent 2ce5c4db6b
commit 4faacdf1be
13 changed files with 715 additions and 70 deletions

View File

@ -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 */,

View File

@ -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")
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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
)
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}
}