Merge pull request #139 from tootsuite/feature/profile-fields

Add profile fields display
This commit is contained in:
CMK 2021-05-27 15:41:05 +08:00 committed by GitHub
commit 680f8509dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1231 additions and 85 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E232" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E241" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -102,6 +102,7 @@
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
@ -273,7 +274,7 @@
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="689"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="704"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>

View File

@ -27,6 +27,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var url: String?
@NSManaged public private(set) var emojisData: Data?
@NSManaged public private(set) var fieldsData: Data?
@NSManaged public private(set) var statusesCount: NSNumber
@NSManaged public private(set) var followingCount: NSNumber
@ -92,6 +93,7 @@ extension MastodonUser {
user.note = property.note
user.url = property.url
user.emojisData = property.emojisData
user.fieldsData = property.fieldsData
user.statusesCount = NSNumber(value: property.statusesCount)
user.followingCount = NSNumber(value: property.followingCount)
@ -161,6 +163,11 @@ extension MastodonUser {
self.emojisData = emojisData
}
}
public func update(fieldsData: Data?) {
if self.fieldsData != fieldsData {
self.fieldsData = fieldsData
}
}
public func update(statusesCount: Int) {
if self.statusesCount.intValue != statusesCount {
self.statusesCount = NSNumber(value: statusesCount)
@ -281,6 +288,7 @@ extension MastodonUser {
public let note: String?
public let url: String?
public let emojisData: Data?
public let fieldsData: Data?
public let statusesCount: Int
public let followingCount: Int
public let followersCount: Int
@ -304,6 +312,7 @@ extension MastodonUser {
note: String?,
url: String?,
emojisData: Data?,
fieldsData: Data?,
statusesCount: Int,
followingCount: Int,
followersCount: Int,
@ -326,6 +335,7 @@ extension MastodonUser {
self.note = note
self.url = url
self.emojisData = emojisData
self.fieldsData = fieldsData
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount

View File

@ -404,6 +404,13 @@
"count_followers": "%ld followers"
}
},
"fields": {
"add_row": "Add Row",
"placeholder": {
"label": "Label",
"content": "Content"
}
},
"segmented_control": {
"posts": "Posts",
"replies": "Replies",

View File

@ -401,6 +401,12 @@
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; };
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; };
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */; };
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */; };
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */; };
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA94439265CC0FC00C537E1 /* Fields.swift */; };
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; };
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; };
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
@ -460,6 +466,8 @@
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; };
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; };
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */; };
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -961,6 +969,12 @@
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = "<group>"; };
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = "<group>"; };
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; };
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = "<group>"; };
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = "<group>"; };
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = "<group>"; };
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; };
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
@ -1019,6 +1033,8 @@
DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; };
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewHeaderFooterView.swift; sourceTree = "<group>"; };
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; sourceTree = "<group>"; };
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -1402,6 +1418,7 @@
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1463,6 +1480,7 @@
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -1619,6 +1637,7 @@
DB6D9F4826353FD6008423CD /* Subscription.swift */,
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
DBAFB7342645463500371D5F /* Emojis.swift */,
DBA94439265CC0FC00C537E1 /* Fields.swift */,
);
path = CoreDataStack;
sourceTree = "<group>";
@ -1913,6 +1932,7 @@
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */,
DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */,
);
path = MastodonSDK;
@ -2345,6 +2365,7 @@
DBB525732612D5A5002F1F29 /* View */,
DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */,
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */,
DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */,
);
path = Header;
sourceTree = "<group>";
@ -2357,6 +2378,9 @@
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */,
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */,
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */,
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */,
DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */,
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
);
path = View;
sourceTree = "<group>";
@ -2921,6 +2945,7 @@
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */,
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
@ -2992,6 +3017,7 @@
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */,
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
@ -3011,6 +3037,7 @@
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
@ -3045,6 +3072,7 @@
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */,
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
@ -3059,6 +3087,7 @@
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
DBA9443A265CC0FC00C537E1 /* Fields.swift in Sources */,
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
@ -3115,6 +3144,7 @@
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
@ -3152,6 +3182,7 @@
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
@ -3261,6 +3292,7 @@
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */,

View File

@ -12,7 +12,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>15</integer>
<integer>14</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -32,7 +32,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>14</integer>
<integer>15</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -0,0 +1,106 @@
//
// ProfileFieldItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import Foundation
import Combine
import MastodonSDK
enum ProfileFieldItem {
case field(field: FieldValue, attribute: FieldItemAttribute)
case addEntry(attribute: AddEntryItemAttribute)
}
protocol ProfileFieldListSeparatorLineConfigurable: AnyObject {
var isLast: Bool { get set }
}
extension ProfileFieldItem {
var listSeparatorLineConfigurable: ProfileFieldListSeparatorLineConfigurable? {
switch self {
case .field(_, let attribute):
return attribute
case .addEntry(let attribute):
return attribute
}
}
}
extension ProfileFieldItem {
struct FieldValue: Equatable, Hashable {
let id: UUID
var name: CurrentValueSubject<String, Never>
var value: CurrentValueSubject<String, Never>
init(id: UUID = UUID(), name: String, value: String) {
self.id = id
self.name = CurrentValueSubject(name)
self.value = CurrentValueSubject(value)
}
func duplicate() -> FieldValue {
FieldValue(name: name.value, value: value.value)
}
static func == (lhs: ProfileFieldItem.FieldValue, rhs: ProfileFieldItem.FieldValue) -> Bool {
return lhs.id == rhs.id
&& lhs.name.value == rhs.name.value
&& lhs.value.value == rhs.value.value
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension ProfileFieldItem {
class FieldItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
var isEditing = false
var isLast = false
static func == (lhs: ProfileFieldItem.FieldItemAttribute, rhs: ProfileFieldItem.FieldItemAttribute) -> Bool {
return lhs.isEditing == rhs.isEditing
&& lhs.isLast == rhs.isLast
}
}
class AddEntryItemAttribute: Equatable, ProfileFieldListSeparatorLineConfigurable {
var isLast = false
static func == (lhs: ProfileFieldItem.AddEntryItemAttribute, rhs: ProfileFieldItem.AddEntryItemAttribute) -> Bool {
return lhs.isLast == rhs.isLast
}
}
}
extension ProfileFieldItem: Equatable {
static func == (lhs: ProfileFieldItem, rhs: ProfileFieldItem) -> Bool {
switch (lhs, rhs) {
case (.field(let fieldLeft, let attributeLeft), .field(let fieldRight, let attributeRight)):
return fieldLeft.id == fieldRight.id
&& attributeLeft == attributeRight
case (.addEntry(let attributeLeft), .addEntry(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
}
}
extension ProfileFieldItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .field(let field, _):
hasher.combine(field.id)
case .addEntry:
hasher.combine(String(describing: ProfileFieldItem.addEntry.self))
}
}
}

View File

@ -55,7 +55,10 @@ extension ComposeStatusSection {
switch item {
case .replyTo(let replyToStatusObjectID):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
managedObjectContext.perform {
// set empty text before retrieve real data to fix pseudo-text display issue
cell.statusView.nameLabel.text = " "
cell.statusView.usernameLabel.text = " "
managedObjectContext.performAndWait {
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
return
}
@ -82,7 +85,7 @@ extension ComposeStatusSection {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
cell.textEditorView.text = attribute.composeContent.value ?? ""
managedObjectContext.perform {
managedObjectContext.performAndWait {
guard let replyToStatusObjectID = replyToStatusObjectID,
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
cell.statusView.headerContainerView.isHidden = true

View File

@ -0,0 +1,128 @@
//
// ProfileFieldSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import os
import UIKit
import Combine
enum ProfileFieldSection: Equatable, Hashable {
case main
}
extension ProfileFieldSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate,
profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate
) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> {
let dataSource = UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>(collectionView: collectionView) {
[
weak profileFieldCollectionViewCellDelegate,
weak profileFieldAddEntryCollectionViewCellDelegate
] collectionView, indexPath, item in
switch item {
case .field(let field, let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self), for: indexPath) as! ProfileFieldCollectionViewCell
let margin = max(0, collectionView.frame.width - collectionView.readableContentGuide.layoutFrame.width)
cell.containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
cell.separatorLineToMarginLeadingLayoutConstraint.constant = margin
// set key
cell.fieldView.titleActiveLabel.configure(field: field.name.value, emojiDict: attribute.emojiDict.value)
cell.fieldView.titleTextField.text = field.name.value
Publishers.CombineLatest(
field.name.removeDuplicates(),
attribute.emojiDict.removeDuplicates()
)
.receive(on: RunLoop.main)
.sink { [weak cell] name, emojiDict in
guard let cell = cell else { return }
cell.fieldView.titleActiveLabel.configure(field: name, emojiDict: emojiDict)
cell.fieldView.titleTextField.text = name
}
.store(in: &cell.disposeBag)
// set value
cell.fieldView.valueActiveLabel.configure(field: field.value.value, emojiDict: attribute.emojiDict.value)
cell.fieldView.valueTextField.text = field.value.value
Publishers.CombineLatest(
field.value.removeDuplicates(),
attribute.emojiDict.removeDuplicates()
)
.receive(on: RunLoop.main)
.sink { [weak cell] value, emojiDict in
guard let cell = cell else { return }
cell.fieldView.valueActiveLabel.configure(field: value, emojiDict: emojiDict)
cell.fieldView.valueTextField.text = value
}
.store(in: &cell.disposeBag)
// bind editing
if attribute.isEditing {
cell.fieldView.name
.removeDuplicates()
.receive(on: RunLoop.main)
.assign(to: \.value, on: field.name)
.store(in: &cell.disposeBag)
cell.fieldView.value
.removeDuplicates()
.receive(on: RunLoop.main)
.assign(to: \.value, on: field.value)
.store(in: &cell.disposeBag)
}
// setup editing state
cell.fieldView.titleTextField.isHidden = !attribute.isEditing
cell.fieldView.valueTextField.isHidden = !attribute.isEditing
cell.fieldView.titleActiveLabel.isHidden = attribute.isEditing
cell.fieldView.valueActiveLabel.isHidden = attribute.isEditing
// set control hidden
let isHidden = !attribute.isEditing
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update editing state: %s", ((#file as NSString).lastPathComponent), #line, #function, isHidden ? "true" : "false")
cell.editButton.isHidden = isHidden
cell.reorderBarImageView.isHidden = isHidden
// update separator line
cell.bottomSeparatorLine.isHidden = attribute.isLast
cell.delegate = profileFieldCollectionViewCellDelegate
return cell
case .addEntry(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self), for: indexPath) as! ProfileFieldAddEntryCollectionViewCell
let margin = max(0, collectionView.frame.width - collectionView.readableContentGuide.layoutFrame.width)
cell.containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)
cell.separatorLineToMarginLeadingLayoutConstraint.constant = margin
cell.bottomSeparatorLine.isHidden = attribute.isLast
cell.delegate = profileFieldAddEntryCollectionViewCellDelegate
return cell
}
}
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
switch kind {
case UICollectionView.elementKindSectionHeader:
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView
return reusableView
case UICollectionView.elementKindSectionFooter:
let reusableView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer, for: indexPath) as! ProfileFieldCollectionViewHeaderFooterView
return reusableView
default:
return nil
}
}
return dataSource
}
}

View File

@ -16,7 +16,8 @@ extension ActiveLabel {
case `default`
case statusHeader
case statusName
case profileField
case profileFieldName
case profileFieldValue
}
convenience init(style: Style) {
@ -46,8 +47,12 @@ extension ActiveLabel {
font = .systemFont(ofSize: 17, weight: .semibold)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
case .profileField:
font = .preferredFont(forTextStyle: .body)
case .profileFieldName:
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
case .profileFieldValue:
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
textColor = Asset.Colors.Label.primary.color
numberOfLines = 1
}
@ -78,10 +83,10 @@ extension ActiveLabel {
extension ActiveLabel {
/// account field
func configure(field: String) {
func configure(field: String, emojiDict: MastodonStatusContent.EmojiDict) {
activeEntities.removeAll()
let parseResult = MastodonField.parse(field: field)
text = parseResult.value
let parseResult = MastodonField.parse(field: field, emojiDict: emojiDict)
text = parseResult.trimmed
activeEntities = parseResult.activeEntities
accessibilityLabel = parseResult.value
}

View File

@ -0,0 +1,27 @@
//
// Fields.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import Foundation
import MastodonSDK
protocol FieldContinaer {
var fieldsData: Data? { get }
}
extension FieldContinaer {
static func encode(fields: [Mastodon.Entity.Field]) -> Data? {
return try? JSONEncoder().encode(fields)
}
var fields: [Mastodon.Entity.Field]? {
let decoder = JSONDecoder()
return fieldsData.flatMap { try? decoder.decode([Mastodon.Entity.Field].self, from: $0) }
}
}

View File

@ -23,7 +23,8 @@ extension MastodonUser.Property {
headerStatic: entity.headerStatic,
note: entity.note,
url: entity.url,
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
emojisData: entity.emojis.flatMap { MastodonUser.encode(emojis: $0) },
fieldsData: entity.fields.flatMap { MastodonUser.encode(fields: $0) },
statusesCount: entity.statusesCount,
followingCount: entity.followingCount,
followersCount: entity.followersCount,
@ -101,3 +102,4 @@ extension MastodonUser {
}
extension MastodonUser: EmojiContinaer { }
extension MastodonUser: FieldContinaer { }

View File

@ -0,0 +1,19 @@
//
// Mastodon+Entity+Field.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import Foundation
import MastodonSDK
extension Mastodon.Entity.Field: Equatable {
public static func == (lhs: Mastodon.Entity.Field, rhs: Mastodon.Entity.Field) -> Bool {
return lhs.name == rhs.name &&
lhs.value == rhs.value &&
lhs.verifiedAt == rhs.verifiedAt
}
}

View File

@ -626,6 +626,16 @@ internal enum L10n {
}
}
}
internal enum Fields {
/// Add Row
internal static let addRow = L10n.tr("Localizable", "Scene.Profile.Fields.AddRow")
internal enum Placeholder {
/// Content
internal static let content = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Content")
/// Label
internal static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label")
}
}
internal enum RelationshipActionAlert {
internal enum ConfirmUnblockUsre {
/// Confirm unblock %@

View File

@ -10,12 +10,25 @@ import ActiveLabel
enum MastodonField {
static func parse(field string: String) -> ParseResult {
static func parse(field string: String, emojiDict: MastodonStatusContent.EmojiDict) -> ParseResult {
// use content parser get emoji entities
let value = string
var string = string
var entities: [ActiveEntity] = []
do {
let contentParseresult = try MastodonStatusContent.parse(content: string, emojiDict: emojiDict)
string = contentParseresult.trimmed
entities.append(contentsOf: contentParseresult.activeEntities)
} catch {
// assertionFailure(error.localizedDescription)
}
let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
var entities: [ActiveEntity] = []
for match in mentionMatches {
guard let text = string.substring(with: match, at: 0) else { continue }
@ -35,7 +48,7 @@ enum MastodonField {
entities.append(entity)
}
return ParseResult(value: string, activeEntities: entities)
return ParseResult(value: value, trimmed: string, activeEntities: entities)
}
}
@ -43,6 +56,7 @@ enum MastodonField {
extension MastodonField {
struct ParseResult {
let value: String
let trimmed: String
let activeEntities: [ActiveEntity]
}
}

View File

@ -23,7 +23,7 @@ enum MastodonStatusContent {
let pattern = ":\(shortcode):"
content = content.replacingOccurrences(of: pattern, with: emojiNode)
}
return content
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}()
let rootNode = try Node.parse(document: document)
let text = String(rootNode.text)

View File

@ -212,6 +212,9 @@ tap the link to confirm your account.";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";

View File

@ -212,6 +212,9 @@ tap the link to confirm your account.";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";
"Scene.Profile.Fields.AddRow" = "Add Row";
"Scene.Profile.Fields.Placeholder.Content" = "Content";
"Scene.Profile.Fields.Placeholder.Label" = "Label";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@";
"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account";
"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@";

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import Combine
import PhotosUI
import ActiveLabel
import AlamofireImage
import CropViewController
import TwitterTextEditor
@ -16,6 +17,7 @@ import TwitterTextEditor
protocol ProfileHeaderViewControllerDelegate: AnyObject {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int)
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
}
final class ProfileHeaderViewController: UIViewController {
@ -96,6 +98,15 @@ extension ProfileHeaderViewController {
])
profileHeaderView.preservesSuperviewLayoutMargins = true
profileHeaderView.fieldCollectionView.delegate = self
viewModel.setupProfileFieldCollectionViewDiffableDataSource(
collectionView: profileHeaderView.fieldCollectionView,
profileFieldCollectionViewCellDelegate: self,
profileFieldAddEntryCollectionViewCellDelegate: self
)
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ProfileHeaderViewController.longPressReorderGestureHandler(_:)))
profileHeaderView.fieldCollectionView.addGestureRecognizer(longPressReorderGesture)
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageSegmentedControl)
NSLayoutConstraint.activate([
@ -190,6 +201,25 @@ extension ProfileHeaderViewController {
}
.store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.isEditing,
viewModel.displayProfileInfo.fields
)
.receive(on: RunLoop.main)
.sink { [weak self] isEditing, fields in
guard let self = self else { return }
self.profileHeaderView.fieldCollectionView.isHidden = isEditing ? false : fields.isEmpty
}
.store(in: &disposeBag)
viewModel.isEditing
.receive(on: RunLoop.main)
.sink { [weak self] isEditing in
guard let self = self else { return }
// self.profileHeaderView.fieldCollectionView.
}
.store(in: &disposeBag)
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
}
@ -265,6 +295,48 @@ extension ProfileHeaderViewController {
delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex)
}
// seealso: ProfileHeaderViewModel.setupProfileFieldCollectionViewDiffableDataSource()
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
guard sender.view === profileHeaderView.fieldCollectionView else {
assertionFailure()
return
}
let collectionView = profileHeaderView.fieldCollectionView
switch(sender.state) {
case .began:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ProfileFieldCollectionViewCell else {
break
}
// check if pressing reorder bar no not
let locationInCell = sender.location(in: cell.reorderBarImageView)
guard cell.reorderBarImageView.bounds.contains(locationInCell) else {
return
}
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let diffableDataSource = viewModel.fieldDiffableDataSource else {
break
}
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
case .field = item else {
collectionView.cancelInteractiveMovement()
return
}
var position = sender.location(in: collectionView)
position.x = collectionView.frame.width * 0.5
collectionView.updateInteractiveMovementTargetPosition(position)
case .ended:
collectionView.endInteractiveMovement()
collectionView.reloadData()
default:
collectionView.cancelInteractiveMovement()
}
}
}
extension ProfileHeaderViewController {
@ -290,7 +362,7 @@ extension ProfileHeaderViewController {
}
}
func updateHeaderScrollProgress(_ progress: CGFloat) {
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
updateHeaderBottomShadow(progress: progress)
@ -336,12 +408,12 @@ extension ProfileHeaderViewController {
viewModel.isTitleViewContentOffsetSet.value = true
}
// set avatar
// set avatar fade
if progress > 0 {
setProfileBannerFade(alpha: 0)
} else if progress > -0.3 {
// y = -(10/3)x
let alpha = -10.0 / 3.0 * progress
} else if progress > -abs(throttle) {
// y = -(1/0.8T)x
let alpha = -1 / abs(0.8 * throttle) * progress
setProfileBannerFade(alpha: alpha)
} else {
setProfileBannerFade(alpha: 1)
@ -435,3 +507,28 @@ extension ProfileHeaderViewController: CropViewControllerDelegate {
}
}
// MARK: - UICollectionViewDelegate
extension ProfileHeaderViewController: UICollectionViewDelegate {
}
// MARK: - ProfileFieldCollectionViewCellDelegate
extension ProfileHeaderViewController: ProfileFieldCollectionViewCellDelegate {
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.fieldDiffableDataSource else { return }
guard let indexPath = profileHeaderView.fieldCollectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.removeFieldItem(item: item)
}
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
delegate?.profileHeaderViewController(self, profileFieldCollectionViewCell: cell, activeLabel: activeLabel, didSelectActiveEntity: entity)
}
}
// MARK: - ProfileFieldAddEntryCollectionViewCellDelegate
extension ProfileHeaderViewController: ProfileFieldAddEntryCollectionViewCellDelegate {
func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell) {
viewModel.appendFieldItem()
}
}

View File

@ -0,0 +1,43 @@
//
// ProfileHeaderViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import UIKit
extension ProfileHeaderViewModel {
func setupProfileFieldCollectionViewDiffableDataSource(
collectionView: UICollectionView,
profileFieldCollectionViewCellDelegate: ProfileFieldCollectionViewCellDelegate,
profileFieldAddEntryCollectionViewCellDelegate: ProfileFieldAddEntryCollectionViewCellDelegate
) {
let diffableDataSource = ProfileFieldSection.collectionViewDiffableDataSource(
for: collectionView,
profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate,
profileFieldAddEntryCollectionViewCellDelegate: profileFieldAddEntryCollectionViewCellDelegate
)
diffableDataSource.reorderingHandlers.canReorderItem = { item in
switch item {
case .field: return true
default: return false
}
}
diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in
guard let self = self else { return }
let items = transaction.finalSnapshot.itemIdentifiers
var fieldValues: [ProfileFieldItem.FieldValue] = []
for item in items {
guard case let .field(field, _) = item else { continue }
fieldValues.append(field)
}
self.editProfileInfo.fields.value = fieldValues
}
fieldDiffableDataSource = diffableDataSource
}
}

View File

@ -5,6 +5,7 @@
// Created by MainasuK Cirno on 2021-4-9.
//
import os.log
import UIKit
import Combine
import Kanna
@ -12,6 +13,8 @@ import MastodonSDK
final class ProfileHeaderViewModel {
static let maxProfileFieldCount = 4
var disposeBag = Set<AnyCancellable>()
// input
@ -20,11 +23,13 @@ final class ProfileHeaderViewModel {
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
// output
let displayProfileInfo = ProfileInfo()
let editProfileInfo = ProfileInfo()
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
var fieldDiffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>!
init(context: AppContext) {
self.context = context
@ -35,11 +40,64 @@ final class ProfileHeaderViewModel {
.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.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)
self.editProfileInfo.fields.value = self.displayProfileInfo.fields.value.map { $0.duplicate() } // set to fields
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
isEditing.removeDuplicates(),
displayProfileInfo.fields.removeDuplicates(),
editProfileInfo.fields.removeDuplicates(),
emojiDict.removeDuplicates()
)
.receive(on: RunLoop.main)
.sink { [weak self] isEditing, displayFields, editingFields, emojiDict in
guard let self = self else { return }
guard let diffableDataSource = self.fieldDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main])
let oldSnapshot = diffableDataSource.snapshot()
let oldFieldAttributeDict: [UUID: ProfileFieldItem.FieldItemAttribute] = {
var dict: [UUID: ProfileFieldItem.FieldItemAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
switch item {
case .field(let field, let attribute):
dict[field.id] = attribute
default:
continue
}
}
return dict
}()
let fields: [ProfileFieldItem.FieldValue] = isEditing ? editingFields : displayFields
var items = fields.map { field -> ProfileFieldItem in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: process field item ID: %s", ((#file as NSString).lastPathComponent), #line, #function, field.id.uuidString)
let attribute = oldFieldAttributeDict[field.id] ?? ProfileFieldItem.FieldItemAttribute()
attribute.isEditing = isEditing
attribute.emojiDict.value = emojiDict
attribute.isLast = false
return ProfileFieldItem.field(field: field, attribute: attribute)
}
if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount {
items.append(.addEntry(attribute: ProfileFieldItem.AddEntryItemAttribute()))
}
if let last = items.last?.listSeparatorLineConfigurable {
last.isLast = true
}
snapshot.appendItems(items, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
}
}
@ -49,6 +107,7 @@ extension ProfileHeaderViewModel {
let name = CurrentValueSubject<String?, Never>(nil)
let avatarImageResource = CurrentValueSubject<ImageResource?, Never>(nil)
let note = CurrentValueSubject<String?, Never>(nil)
let fields = CurrentValueSubject<[ProfileFieldItem.FieldValue], Never>([])
enum ImageResource {
case url(URL?)
@ -57,6 +116,23 @@ extension ProfileHeaderViewModel {
}
}
extension ProfileHeaderViewModel {
func appendFieldItem() {
var fields = editProfileInfo.fields.value
guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return }
fields.append(ProfileFieldItem.FieldValue(name: "", value: ""))
editProfileInfo.fields.value = fields
}
func removeFieldItem(item: ProfileFieldItem) {
var fields = editProfileInfo.fields.value
guard case let .field(field, _) = item else { return }
guard let removeIndex = fields.firstIndex(of: field) else { return }
fields.remove(at: removeIndex)
editProfileInfo.fields.value = fields
}
}
extension ProfileHeaderViewModel {
static func normalize(note: String?) -> String? {
@ -75,6 +151,19 @@ extension ProfileHeaderViewModel {
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 }
let isFieldsEqual: Bool = {
let editFields = editProfileInfo.fields.value
let displayFields = displayProfileInfo.fields.value
guard editFields.count == displayFields.count else { return false }
for (editField, displayField) in zip(editFields, displayFields) {
guard editField.name.value == displayField.name.value,
editField.value.value == displayField.value.value else {
return false
}
}
return true
}()
guard isFieldsEqual else { return true }
return false
}
@ -95,6 +184,10 @@ extension ProfileHeaderViewModel {
return image
}()
let fieldsAttributes = editProfileInfo.fields.value.map { fieldValue in
Mastodon.Entity.Field(name: fieldValue.name.value, value: fieldValue.value.value)
}
let query = Mastodon.API.Account.UpdateCredentialQuery(
discoverable: nil,
bot: nil,
@ -104,7 +197,7 @@ extension ProfileHeaderViewModel {
header: nil,
locked: nil,
source: nil,
fieldsAttributes: nil // TODO:
fieldsAttributes: fieldsAttributes
)
return context.apiService.accountUpdateCredentials(
domain: domain,

View File

@ -0,0 +1,178 @@
//
// ProfileFieldAddEntryCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-26.
//
import os.log
import UIKit
import Combine
protocol ProfileFieldAddEntryCollectionViewCellDelegate: AnyObject {
func ProfileFieldAddEntryCollectionViewCellDidPressed(_ cell: ProfileFieldAddEntryCollectionViewCell)
}
final class ProfileFieldAddEntryCollectionViewCell: UICollectionViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: ProfileFieldAddEntryCollectionViewCellDelegate?
let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
static let symbolConfiguration = ProfileFieldCollectionViewCell.symbolConfiguration
static let insertButtonImage = UIImage(systemName: "plus.circle.fill", withConfiguration: symbolConfiguration)
let containerStackView = UIStackView()
let fieldView = ProfileFieldView()
let editButton: UIButton = {
let button = HitTestExpandedButton(type: .custom)
button.setImage(ProfileFieldAddEntryCollectionViewCell.insertButtonImage, for: .normal)
button.contentMode = .center
button.tintColor = .systemGreen
return button
}()
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
let bottomSeparatorLine = UIView.separatorLine
override func prepareForReuse() {
super.prepareForReuse()
//resetStackView()
disposeBag.removeAll()
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFieldAddEntryCollectionViewCell {
private func _init() {
containerStackView.axis = .horizontal
containerStackView.spacing = 8
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
])
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.addArrangedSubview(editButton)
containerStackView.addArrangedSubview(fieldView)
editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
editButton.setContentHuggingPriority(.required - 1, for: .horizontal)
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
addSubview(bottomSeparatorLine)
NSLayoutConstraint.activate([
separatorLineToMarginLeadingLayoutConstraint,
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
fieldView.titleActiveLabel.isHidden = false
fieldView.titleActiveLabel.configure(field: L10n.Scene.Profile.Fields.addRow, emojiDict: [:])
fieldView.titleTextField.isHidden = true
fieldView.valueActiveLabel.isHidden = false
fieldView.valueActiveLabel.configure(field: " ", emojiDict: [:])
fieldView.valueTextField.isHidden = true
addGestureRecognizer(singleTagGestureRecognizer)
singleTagGestureRecognizer.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.singleTapGestureRecognizerHandler(_:)))
editButton.addTarget(self, action: #selector(ProfileFieldAddEntryCollectionViewCell.addButtonDidPressed(_:)), for: .touchUpInside)
resetSeparatorLineLayout()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
resetSeparatorLineLayout()
}
}
extension ProfileFieldAddEntryCollectionViewCell {
@objc private func singleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self)
}
@objc private func addButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.ProfileFieldAddEntryCollectionViewCellDidPressed(self)
}
}
extension ProfileFieldAddEntryCollectionViewCell {
private func resetSeparatorLineLayout() {
separatorLineToEdgeTrailingLayoutConstraint.isActive = false
separatorLineToMarginTrailingLayoutConstraint.isActive = false
if traitCollection.userInterfaceIdiom == .phone {
// to edge
NSLayoutConstraint.activate([
separatorLineToEdgeTrailingLayoutConstraint,
])
} else {
if traitCollection.horizontalSizeClass == .compact {
// to edge
NSLayoutConstraint.activate([
separatorLineToEdgeTrailingLayoutConstraint,
])
} else {
// to margin
NSLayoutConstraint.activate([
separatorLineToMarginTrailingLayoutConstraint,
])
}
}
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ProfileFieldAddEntryCollectionViewCell_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
ProfileFieldAddEntryCollectionViewCell()
}
.previewLayout(.fixed(width: 375, height: 44))
}
}
#endif

View File

@ -0,0 +1,182 @@
//
// ProfileFieldCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-25.
//
import os.log
import UIKit
import Combine
import ActiveLabel
protocol ProfileFieldCollectionViewCellDelegate: AnyObject {
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, editButtonDidPressed button: UIButton)
func profileFieldCollectionViewCell(_ cell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
}
final class ProfileFieldCollectionViewCell: UICollectionViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: ProfileFieldCollectionViewCellDelegate?
static let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold, scale: .medium)
static let removeButtonItem = UIImage(systemName: "minus.circle.fill", withConfiguration: symbolConfiguration)
let containerStackView = UIStackView()
let fieldView = ProfileFieldView()
let editButton: UIButton = {
let button = HitTestExpandedButton(type: .custom)
button.setImage(ProfileFieldCollectionViewCell.removeButtonItem, for: .normal)
button.contentMode = .center
button.tintColor = .systemRed
return button
}()
let reorderBarImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Label.secondary.color
return imageView
}()
var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
let bottomSeparatorLine = UIView.separatorLine
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFieldCollectionViewCell {
private func _init() {
containerStackView.axis = .horizontal
containerStackView.spacing = 8
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
])
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.addArrangedSubview(editButton)
containerStackView.addArrangedSubview(fieldView)
containerStackView.addArrangedSubview(reorderBarImageView)
editButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
editButton.setContentHuggingPriority(.required - 1, for: .horizontal)
reorderBarImageView.setContentHuggingPriority(.required - 1, for: .horizontal)
reorderBarImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
separatorLineToMarginLeadingLayoutConstraint = bottomSeparatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
separatorLineToEdgeTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
separatorLineToMarginTrailingLayoutConstraint = bottomSeparatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
addSubview(bottomSeparatorLine)
NSLayoutConstraint.activate([
separatorLineToMarginLeadingLayoutConstraint,
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
editButton.addTarget(self, action: #selector(ProfileFieldCollectionViewCell.editButtonDidPressed(_:)), for: .touchUpInside)
fieldView.valueActiveLabel.delegate = self
resetSeparatorLineLayout()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
resetSeparatorLineLayout()
}
}
extension ProfileFieldCollectionViewCell {
private func resetSeparatorLineLayout() {
separatorLineToEdgeTrailingLayoutConstraint.isActive = false
separatorLineToMarginTrailingLayoutConstraint.isActive = false
if traitCollection.userInterfaceIdiom == .phone {
// to edge
NSLayoutConstraint.activate([
separatorLineToEdgeTrailingLayoutConstraint,
])
} else {
if traitCollection.horizontalSizeClass == .compact {
// to edge
NSLayoutConstraint.activate([
separatorLineToEdgeTrailingLayoutConstraint,
])
} else {
// to margin
NSLayoutConstraint.activate([
separatorLineToMarginTrailingLayoutConstraint,
])
}
}
}
}
extension ProfileFieldCollectionViewCell {
@objc private func editButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.profileFieldCollectionViewCell(self, editButtonDidPressed: sender)
}
}
// MARK: - ActiveLabelDelegate
extension ProfileFieldCollectionViewCell: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.profileFieldCollectionViewCell(self, activeLabel: activeLabel, didSelectActiveEntity: entity)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ProfileFieldCollectionViewCell_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
ProfileFieldCollectionViewCell()
}
.previewLayout(.fixed(width: 375, height: 44))
}
}
#endif

View File

@ -0,0 +1,41 @@
//
// ProfileFieldCollectionViewHeaderFooterView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-26.
//
import UIKit
final class ProfileFieldCollectionViewHeaderFooterView: UICollectionReusableView {
static let headerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Header"
static let footerReuseIdentifer = "ProfileFieldCollectionViewHeaderFooterView.Footer"
let separatorLine = UIView.separatorLine
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ProfileFieldCollectionViewHeaderFooterView {
private func _init() {
separatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.topAnchor.constraint(equalTo: topAnchor),
separatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
separatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
}
}

View File

@ -6,26 +6,50 @@
//
import UIKit
import Combine
import ActiveLabel
final class ProfileFieldView: UIView {
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = "Title"
var disposeBag = Set<AnyCancellable>()
// output
let name = PassthroughSubject<String, Never>()
let value = PassthroughSubject<String, Never>()
// for custom emoji display
let titleActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileFieldName)
label.configure(content: "title", emojiDict: [:])
return label
}()
// for editing
let titleTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20)
textField.textColor = Asset.Colors.Label.primary.color
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.label
textField.isEnabled = false
return textField
}()
// for custom emoji display
let valueActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileField)
let label = ActiveLabel(style: .profileFieldValue)
label.configure(content: "value", emojiDict: [:])
return label
}()
let topSeparatorLine = UIView.separatorLine
let bottomSeparatorLine = UIView.separatorLine
// for editing
let valueTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 20)
textField.textColor = Asset.Colors.Label.primary.color
textField.placeholder = L10n.Scene.Profile.Fields.Placeholder.content
textField.textAlignment = .right
return textField
}()
override init(frame: CGRect) {
super.init(frame: frame)
@ -41,42 +65,67 @@ final class ProfileFieldView: UIView {
extension ProfileFieldView {
private func _init() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .center
// note:
// do not use readable layout guide to workaround SDK issue
// otherwise, the `ProfileFieldCollectionViewCell` cannot display edit button and reorder icon
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: topAnchor),
titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh),
containerStackView.topAnchor.constraint(equalTo: topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
titleActiveLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(titleActiveLabel)
NSLayoutConstraint.activate([
titleActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
titleTextField.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(titleTextField)
NSLayoutConstraint.activate([
titleTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
titleTextField.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(valueActiveLabel)
containerStackView.addArrangedSubview(valueActiveLabel)
NSLayoutConstraint.activate([
valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
valueActiveLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(topSeparatorLine)
valueActiveLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
valueTextField.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(valueTextField)
NSLayoutConstraint.activate([
topSeparatorLine.topAnchor.constraint(equalTo: topAnchor),
topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor),
topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
valueTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomSeparatorLine)
NSLayoutConstraint.activate([
bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor),
bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh),
])
titleTextField.isHidden = true
valueTextField.isHidden = true
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: titleTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.name.send(self.titleTextField.text ?? "")
}
.store(in: &disposeBag)
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: valueTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.value.send(self.valueTextField.text ?? "")
}
.store(in: &disposeBag)
}
}
@ -88,7 +137,7 @@ struct ProfileFieldView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
let filedView = ProfileFieldView()
filedView.valueActiveLabel.configure(field: "https://mastodon.online")
filedView.valueActiveLabel.configure(field: "https://mastodon.online", emojiDict: [:])
return filedView
}
.previewLayout(.fixed(width: 375, height: 100))

View File

@ -17,8 +17,8 @@ protocol ProfileHeaderViewDelegate: AnyObject {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView)
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView)
}
final class ProfileHeaderView: UIView {
@ -80,6 +80,7 @@ final class ProfileHeaderView: UIView {
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius
view.alpha = 0 // set initial state invisible
return view
}()
@ -152,6 +153,37 @@ final class ProfileHeaderView: UIView {
return textEditorView
}()
static func createFieldCollectionViewLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
// note: manually set layout inset to workaround header footer layout issue
// section.contentInsetsReference = .readableContent
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(1))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
section.boundarySupplementaryItems = [header, footer]
return UICollectionViewCompositionalLayout(section: section)
}
let fieldCollectionView: UICollectionView = {
let collectionViewLayout = ProfileHeaderView.createFieldCollectionViewLayout()
let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), collectionViewLayout: collectionViewLayout)
collectionView.register(ProfileFieldCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldCollectionViewCell.self))
collectionView.register(ProfileFieldAddEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ProfileFieldAddEntryCollectionViewCell.self))
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer)
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.footerReuseIdentifer)
collectionView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
collectionView.isScrollEnabled = false
return collectionView
}()
var fieldCollectionViewHeightLaoutConstraint: NSLayoutConstraint!
var fieldCollectionViewHeightObservation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -162,6 +194,10 @@ final class ProfileHeaderView: UIView {
_init()
}
deinit {
fieldCollectionViewHeightObservation = nil
}
}
extension ProfileHeaderView {
@ -193,24 +229,24 @@ extension ProfileHeaderView {
])
// avatar
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(avatarImageView)
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(avatarImageViewBackgroundView)
NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1),
avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1),
avatarImageViewBackgroundView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor),
bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageViewBackgroundView.bottomAnchor, constant: 20),
])
avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageViewBackgroundView.addSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth),
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([
@ -328,8 +364,20 @@ extension ProfileHeaderView {
bioContainerStackView.addArrangedSubview(bioActiveLabelContainer)
bioContainerStackView.addArrangedSubview(bioTextEditorView)
fieldContainerStackView.preservesSuperviewLayoutMargins = true
metaContainerStackView.addSubview(fieldContainerStackView)
fieldCollectionView.translatesAutoresizingMaskIntoConstraints = false
metaContainerStackView.addArrangedSubview(fieldCollectionView)
fieldCollectionViewHeightLaoutConstraint = fieldCollectionView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh)
NSLayoutConstraint.activate([
fieldCollectionViewHeightLaoutConstraint,
])
fieldCollectionViewHeightObservation = fieldCollectionView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in
guard let self = self else { return }
guard self.fieldCollectionView.contentSize.height != .zero else {
self.fieldCollectionViewHeightLaoutConstraint.constant = 44
return
}
self.fieldCollectionViewHeightLaoutConstraint.constant = self.fieldCollectionView.contentSize.height
})
bringSubviewToFront(bannerContainerView)
bringSubviewToFront(nameContainerStackView)

View File

@ -368,6 +368,18 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag)
viewModel.fileds
.removeDuplicates()
.map { fields -> [ProfileFieldItem.FieldValue] in
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) }
}
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.fields)
.store(in: &disposeBag)
viewModel.emojiDict
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.emojiDict)
.store(in: &disposeBag)
viewModel.username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.receive(on: DispatchQueue.main)
@ -640,7 +652,8 @@ extension ProfileViewController: UIScrollViewDelegate {
// elastically banner image
let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress)
let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY
profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle)
}
}
@ -664,6 +677,19 @@ extension ProfileViewController: ProfileHeaderViewControllerDelegate {
)
}
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
switch entity.type {
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .hashtag(let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
default:
break
}
}
}
// MARK: - ProfilePagingViewControllerDelegate
@ -852,20 +878,27 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .mention(_, let userInfo):
guard let href = userInfo?["href"] as? String,
let url = URL(string: href) else { return }
coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
case .hashtag(let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show)
default:
// TODO:
break
}
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed followingDashboardMeterView: ProfileStatusDashboardMeterView) {
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed followersDashboardMeterView: ProfileStatusDashboardMeterView) {
}
}

View File

@ -39,6 +39,8 @@ class ProfileViewModel: NSObject {
let statusesCount: CurrentValueSubject<Int?, Never>
let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never>
let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never>
let emojiDict: CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>
let protected: CurrentValueSubject<Bool?, Never>
let suspended: CurrentValueSubject<Bool, Never>
@ -75,6 +77,8 @@ class ProfileViewModel: NSObject {
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked)
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
self.fileds = CurrentValueSubject(mastodonUser?.fields ?? [])
self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:])
super.init()
relationshipActionOptionSet
@ -231,6 +235,8 @@ extension ProfileViewModel {
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false
self.fileds.value = mastodonUser?.fields ?? []
self.emojiDict.value = mastodonUser?.emojiDict ?? [:]
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {

View File

@ -98,6 +98,8 @@ extension APIService.CoreData {
user.update(locked: property.locked)
property.bot.flatMap { user.update(bot: $0) }
property.suspended.flatMap { user.update(suspended: $0) }
property.emojisData.flatMap { user.update(emojisData: $0) }
property.fieldsData.flatMap { user.update(fieldsData: $0) }
user.didUpdate(at: networkDate)
}

View File

@ -217,11 +217,9 @@ extension Mastodon.API.Account {
source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
}
fieldsAttributes.flatMap { fieldsAttributes in
for fieldsAttribute in fieldsAttributes {
data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name))
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
}
for (i, fieldsAttribute) in (fieldsAttributes ?? []).enumerated() {
data.append(Data.multipart(key: "fields_attributes[\(i)][name]", value: fieldsAttribute.name))
data.append(Data.multipart(key: "fields_attributes[\(i)][value]", value: fieldsAttribute.value))
}
data.append(Data.multipartEnd())

View File

@ -27,5 +27,11 @@ extension Mastodon.Entity {
case value
case verifiedAt = "verified_at"
}
public init(name: String, value: String, verifiedAt: Date? = nil) {
self.name = name
self.value = value
self.verifiedAt = verifiedAt
}
}
}