diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 08369d6..a39acf6 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +"account.field.verified" = "Verified %@"; "account.statuses" = "Posts"; "account.statuses-and-replies" = "Posts & Replies"; "account.media" = "Media"; @@ -32,6 +33,7 @@ "lists.new-list-title" = "New List Title"; "load-more" = "Load More"; "messages" = "Messages"; +"ok" = "OK"; "pending.pending-confirmation" = "Your account is pending confirmation"; "preferences" = "Preferences"; "preferences.app" = "App Preferences"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index cd62d81..5e0ac85 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ D00702312555F4AE00F38136 /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702302555F4AE00F38136 /* ConversationView.swift */; }; D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */; }; D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007023D25562A2800F38136 /* ConversationAvatarsView.swift */; }; + D0070252255921B100F38136 /* AccountFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0070251255921B100F38136 /* AccountFieldView.swift */; }; D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CB2EC2533ACC00080096B /* StatusView.swift */; }; D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; }; D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; }; @@ -124,6 +125,7 @@ D00702302555F4AE00F38136 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContentConfiguration.swift; sourceTree = ""; }; D007023D25562A2800F38136 /* ConversationAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationAvatarsView.swift; sourceTree = ""; }; + D0070251255921B100F38136 /* AccountFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFieldView.swift; sourceTree = ""; }; D00CB2EC2533ACC00080096B /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; D01C6FAB252024BD003D0300 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = ""; }; @@ -342,6 +344,7 @@ isa = PBXGroup; children = ( D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */, + D0070251255921B100F38136 /* AccountFieldView.swift */, D01EF22325182B1F00650C6B /* AccountHeaderView.swift */, D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B10D251A868200942152 /* AccountView.swift */, @@ -674,6 +677,7 @@ D00702312555F4AE00F38136 /* ConversationView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */, + D0070252255921B100F38136 /* AccountFieldView.swift in Sources */, D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */, D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */, D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */, diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index da02251..dd68bf7 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -34,6 +34,8 @@ public extension AccountViewModel { var accountName: String { "@".appending(accountService.account.acct) } + var fields: [Account.Field] { accountService.account.fields } + var note: NSAttributedString { accountService.account.note.attributed } var emoji: [Emoji] { accountService.account.emojis } diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 14d8180..86d1440 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -49,6 +49,12 @@ public extension ProfileViewModel { imagePresentationsSubject.send(accountViewModel.headerURL) } + + func presentAvatar() { + guard let accountViewModel = accountViewModel else { return } + + imagePresentationsSubject.send(accountViewModel.avatarURL(profile: true)) + } } extension ProfileViewModel: CollectionViewModel { diff --git a/Views/AccountFieldView.swift b/Views/AccountFieldView.swift new file mode 100644 index 0000000..a4cb387 --- /dev/null +++ b/Views/AccountFieldView.swift @@ -0,0 +1,154 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Mastodon +import UIKit + +final class AccountFieldView: UIView { + let nameLabel = UILabel() + let valueTextView = TouchFallthroughTextView() + + // swiftlint:disable:next function_body_length + init(field: Account.Field, emoji: [Emoji]) { + super.init(frame: .zero) + + let verified = field.verifiedAt != nil + + backgroundColor = .systemBackground + + let nameBackgroundView = UIView() + + addSubview(nameBackgroundView) + nameBackgroundView.translatesAutoresizingMaskIntoConstraints = false + nameBackgroundView.backgroundColor = .secondarySystemBackground + + let valueBackgroundView = UIView() + + addSubview(valueBackgroundView) + valueBackgroundView.translatesAutoresizingMaskIntoConstraints = false + valueBackgroundView.backgroundColor = verified + ? UIColor.systemGreen.withAlphaComponent(0.25) + : .systemBackground + + addSubview(nameLabel) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.numberOfLines = 0 + nameLabel.font = .preferredFont(forTextStyle: .headline) + nameLabel.textAlignment = .center + nameLabel.textColor = .secondaryLabel + + let mutableName = NSMutableAttributedString(string: field.name) + + mutableName.insert(emoji: emoji, view: nameLabel) + mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight) + nameLabel.attributedText = mutableName + + let dividerView = UIView() + + addSubview(dividerView) + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.backgroundColor = .separator + + addSubview(valueTextView) + valueTextView.translatesAutoresizingMaskIntoConstraints = false + valueTextView.isScrollEnabled = false + valueTextView.backgroundColor = .clear + + if verified { + valueTextView.linkTextAttributes = [ + .foregroundColor: UIColor.systemGreen as Any, + .underlineColor: UIColor.clear] + } + + let valueFont = UIFont.preferredFont(forTextStyle: verified ? .headline : .body) + let mutableValue = NSMutableAttributedString(attributedString: field.value.attributed) + let valueRange = NSRange(location: 0, length: mutableValue.length) + + mutableValue.removeAttribute(.font, range: valueRange) + mutableValue.addAttributes( + [.font: valueFont as Any, + .foregroundColor: UIColor.label], + range: valueRange) + mutableValue.insert(emoji: emoji, view: valueTextView) + mutableValue.resizeAttachments(toLineHeight: valueFont.lineHeight) + + valueTextView.attributedText = mutableValue + valueTextView.textAlignment = .center + + let checkButton = UIButton() + + checkButton.setImage( + UIImage( + systemName: "checkmark", + withConfiguration: UIImage.SymbolConfiguration(scale: .small)), + for: .normal) + + addSubview(checkButton) + checkButton.translatesAutoresizingMaskIntoConstraints = false + checkButton.tintColor = .systemGreen + checkButton.isHidden = !verified + checkButton.showsMenuAsPrimaryAction = true + + if let verifiedAt = field.verifiedAt { + checkButton.menu = UIMenu( + title: String.localizedStringWithFormat( + NSLocalizedString("account.field.verified", comment: ""), + Self.dateFormatter.string(from: verifiedAt)), + options: .displayInline, + children: [UIAction(title: NSLocalizedString("ok", comment: "")) { _ in }]) + } + + let nameLabelBottomConstraint = nameLabel.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -.defaultSpacing) + let valueTextViewBottomConstraint = valueTextView.bottomAnchor.constraint( + lessThanOrEqualTo: bottomAnchor, + constant: -.defaultSpacing) + + for constraint in [nameLabelBottomConstraint, valueTextViewBottomConstraint] { + constraint.priority = .justBelowMax + } + + NSLayoutConstraint.activate([ + nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .defaultSpacing), + nameLabel.topAnchor.constraint(equalTo: topAnchor, constant: .defaultSpacing), + nameLabelBottomConstraint, + dividerView.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: .defaultSpacing), + dividerView.topAnchor.constraint(equalTo: topAnchor), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.widthAnchor.constraint(equalToConstant: .hairline), + checkButton.leadingAnchor.constraint(equalTo: dividerView.trailingAnchor, constant: .defaultSpacing), + valueTextView.leadingAnchor.constraint( + equalTo: verified ? checkButton.trailingAnchor : dividerView.trailingAnchor, + constant: .defaultSpacing), + valueTextView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: .defaultSpacing), + valueTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -.defaultSpacing), + valueTextViewBottomConstraint, + nameLabel.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1 / 3), + checkButton.centerYAnchor.constraint(equalTo: valueTextView.centerYAnchor), + valueTextView.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), + nameBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + nameBackgroundView.topAnchor.constraint(equalTo: topAnchor), + nameBackgroundView.trailingAnchor.constraint(equalTo: dividerView.leadingAnchor), + nameBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + valueBackgroundView.leadingAnchor.constraint(equalTo: dividerView.trailingAnchor), + valueBackgroundView.topAnchor.constraint(equalTo: topAnchor), + valueBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + valueBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension AccountFieldView { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .full + + return formatter + }() +} diff --git a/Views/AccountHeaderView.swift b/Views/AccountHeaderView.swift index 3e53e20..eef7dda 100644 --- a/Views/AccountHeaderView.swift +++ b/Views/AccountHeaderView.swift @@ -7,6 +7,11 @@ import ViewModels final class AccountHeaderView: UIView { let headerImageView = AnimatedImageView() let headerButton = UIButton() + let avatarImageView = UIImageView() + let avatarButton = UIButton() + let displayNameLabel = UILabel() + let accountLabel = UILabel() + let fieldsStackView = UIStackView() let noteTextView = TouchFallthroughTextView() let segmentedControl = UISegmentedControl() @@ -15,6 +20,35 @@ final class AccountHeaderView: UIView { if let accountViewModel = viewModel?.accountViewModel { headerImageView.kf.setImage(with: accountViewModel.headerURL) headerImageView.tag = accountViewModel.headerURL.hashValue + avatarImageView.kf.setImage(with: accountViewModel.avatarURL(profile: true)) + avatarImageView.tag = accountViewModel.avatarURL(profile: true).hashValue + + if accountViewModel.displayName.isEmpty { + displayNameLabel.isHidden = true + } else { + let mutableDisplayName = NSMutableAttributedString(string: accountViewModel.displayName) + + mutableDisplayName.insert(emoji: accountViewModel.emoji, view: displayNameLabel) + mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight) + displayNameLabel.attributedText = mutableDisplayName + } + + accountLabel.text = accountViewModel.accountName + + for view in fieldsStackView.arrangedSubviews { + fieldsStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for field in accountViewModel.fields { + let fieldView = AccountFieldView(field: field, emoji: accountViewModel.emoji) + + fieldView.valueTextView.delegate = self + + fieldsStackView.addArrangedSubview(fieldView) + } + + fieldsStackView.isHidden = accountViewModel.fields.isEmpty let noteFont = UIFont.preferredFont(forTextStyle: .callout) let mutableNote = NSMutableAttributedString(attributedString: accountViewModel.note) @@ -64,6 +98,8 @@ extension AccountHeaderView: UITextViewDelegate { } private extension AccountHeaderView { + static let avatarDimension = CGFloat.avatarDimension * 2 + // swiftlint:disable:next function_body_length func initialSetup() { let baseStackView = UIStackView() @@ -78,17 +114,51 @@ private extension AccountHeaderView { headerButton.translatesAutoresizingMaskIntoConstraints = false headerButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted) - headerButton.addAction( - UIAction { [weak self] _ in self?.viewModel?.presentHeader() }, - for: .touchUpInside) + headerButton.addAction(UIAction { [weak self] _ in self?.viewModel?.presentHeader() }, for: .touchUpInside) + + addSubview(avatarImageView) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.isUserInteractionEnabled = true + avatarImageView.layer.cornerRadius = Self.avatarDimension / 2 + avatarImageView.layer.borderWidth = .compactSpacing + avatarImageView.layer.borderColor = UIColor.systemBackground.cgColor + + avatarImageView.addSubview(avatarButton) + avatarButton.translatesAutoresizingMaskIntoConstraints = false + avatarButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted) + + avatarButton.addAction(UIAction { [weak self] _ in self?.viewModel?.presentAvatar() }, for: .touchUpInside) addSubview(baseStackView) baseStackView.translatesAutoresizingMaskIntoConstraints = false baseStackView.axis = .vertical + baseStackView.spacing = .defaultSpacing + baseStackView.addArrangedSubview(displayNameLabel) + displayNameLabel.numberOfLines = 0 + displayNameLabel.font = .preferredFont(forTextStyle: .headline) + displayNameLabel.adjustsFontForContentSizeCategory = true + + baseStackView.addArrangedSubview(accountLabel) + accountLabel.numberOfLines = 0 + accountLabel.font = .preferredFont(forTextStyle: .subheadline) + accountLabel.adjustsFontForContentSizeCategory = true + accountLabel.textColor = .secondaryLabel + + baseStackView.addArrangedSubview(fieldsStackView) + fieldsStackView.axis = .vertical + fieldsStackView.spacing = .hairline + fieldsStackView.backgroundColor = .separator + fieldsStackView.clipsToBounds = true + fieldsStackView.layer.borderColor = UIColor.separator.cgColor + fieldsStackView.layer.borderWidth = .hairline + fieldsStackView.layer.cornerRadius = .defaultCornerRadius + + baseStackView.addArrangedSubview(noteTextView) noteTextView.isScrollEnabled = false noteTextView.delegate = self - baseStackView.addArrangedSubview(noteTextView) for (index, collection) in ProfileCollection.allCases.enumerated() { segmentedControl.insertSegment( @@ -119,10 +189,18 @@ private extension AccountHeaderView { headerButton.topAnchor.constraint(equalTo: headerImageView.topAnchor), headerButton.bottomAnchor.constraint(equalTo: headerImageView.bottomAnchor), headerButton.trailingAnchor.constraint(equalTo: headerImageView.trailingAnchor), - baseStackView.topAnchor.constraint(equalTo: headerImageView.bottomAnchor), + avatarImageView.heightAnchor.constraint(equalToConstant: Self.avatarDimension), + avatarImageView.widthAnchor.constraint(equalToConstant: Self.avatarDimension), + avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + avatarImageView.centerYAnchor.constraint(equalTo: headerImageView.bottomAnchor), + avatarButton.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), + avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor), + avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + baseStackView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: .defaultSpacing), baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - baseStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + baseStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor) ]) } }