diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index c8c07fbcb..b41a1c280 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -102,6 +102,7 @@ + @@ -273,7 +274,7 @@ - + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index e93d923c0..d442ec753 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -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 diff --git a/Localization/app.json b/Localization/app.json index 8bd1316ab..920ec0d27 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -404,6 +404,13 @@ "count_followers": "%ld followers" } }, + "fields": { + "add_row": "Add Row", + "placeholder": { + "label": "Label", + "content": "Content" + } + }, "segmented_control": { "posts": "Posts", "replies": "Replies", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b016a337c..28f113616 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = ""; }; + DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldSection.swift; sourceTree = ""; }; + DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldItem.swift; sourceTree = ""; }; + DBA94437265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderViewModel+Diffable.swift"; sourceTree = ""; }; + DBA94439265CC0FC00C537E1 /* Fields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fields.swift; sourceTree = ""; }; + DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = ""; }; + DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1019,6 +1033,8 @@ DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; + DBF9814B265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldAddEntryCollectionViewCell.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ @@ -1402,6 +1418,7 @@ DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */, + DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */, ); path = Section; sourceTree = ""; @@ -1463,6 +1480,7 @@ DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */, DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, + DBA94435265CBB7400C537E1 /* ProfileFieldItem.swift */, ); path = Item; sourceTree = ""; @@ -1619,6 +1637,7 @@ DB6D9F4826353FD6008423CD /* Subscription.swift */, DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, DBAFB7342645463500371D5F /* Emojis.swift */, + DBA94439265CC0FC00C537E1 /* Fields.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -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 = ""; @@ -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 = ""; @@ -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 */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index f092f9734..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 15 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 14 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/ProfileFieldItem.swift b/Mastodon/Diffiable/Item/ProfileFieldItem.swift new file mode 100644 index 000000000..684bd7d42 --- /dev/null +++ b/Mastodon/Diffiable/Item/ProfileFieldItem.swift @@ -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 + var value: CurrentValueSubject + + 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([:]) + + 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)) + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 0c4ae8832..0940ddfa4 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -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 diff --git a/Mastodon/Diffiable/Section/ProfileFieldSection.swift b/Mastodon/Diffiable/Section/ProfileFieldSection.swift new file mode 100644 index 000000000..0b67b00a7 --- /dev/null +++ b/Mastodon/Diffiable/Section/ProfileFieldSection.swift @@ -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 { + let dataSource = UICollectionViewDiffableDataSource(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 + } +} diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index c7e35c115..2c32bc4fc 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -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 } diff --git a/Mastodon/Extension/CoreDataStack/Fields.swift b/Mastodon/Extension/CoreDataStack/Fields.swift new file mode 100644 index 000000000..795863f88 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Fields.swift @@ -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) } + } + +} + diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 8180b0255..f5cdc1af0 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -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 { } diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Field.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Field.swift new file mode 100644 index 000000000..c7fe63465 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Field.swift @@ -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 + } + + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7253d6ef0..de8403ac0 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -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 %@ diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift index 5f652b32c..12b03c91f 100644 --- a/Mastodon/Helper/MastodonField.swift +++ b/Mastodon/Helper/MastodonField.swift @@ -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] } } diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index 0f9dbc6c0..391519e84 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -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) diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index ceb690459..80fce62fa 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -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 %@"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ceb690459..80fce62fa 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -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 %@"; diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 217ea6584..0de6f47e3 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -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() + } +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift new file mode 100644 index 000000000..b02eaa614 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel+Diffable.swift @@ -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 + } +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 6e4fe2def..d0a29762c 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -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() // input @@ -20,11 +23,13 @@ final class ProfileHeaderViewModel { let viewDidAppear = CurrentValueSubject(false) let needsSetupBottomShadow = CurrentValueSubject(true) let isTitleViewContentOffsetSet = CurrentValueSubject(false) + let emojiDict = CurrentValueSubject([:]) // output let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() let isTitleViewDisplaying = CurrentValueSubject(false) + var fieldDiffableDataSource: UICollectionViewDiffableDataSource! 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() + 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(nil) let avatarImageResource = CurrentValueSubject(nil) let note = CurrentValueSubject(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, diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift new file mode 100644 index 000000000..1801d4222 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldAddEntryCollectionViewCell.swift @@ -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() + + 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 + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift new file mode 100644 index 000000000..58af0ce7e --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewCell.swift @@ -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() + + 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 + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift new file mode 100644 index 000000000..be61691c0 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldCollectionViewHeaderFooterView.swift @@ -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), + ]) + } +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift index 320a495eb..14e5e4b33 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -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() + + // output + let name = PassthroughSubject() + let value = PassthroughSubject() + + // 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)) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index e37427e32..89859c982 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -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) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 7d3c581bb..267e5bcee 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -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) { } } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 445952e96..f9a89909c 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -39,6 +39,8 @@ class ProfileViewModel: NSObject { let statusesCount: CurrentValueSubject let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject + let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never> + let emojiDict: CurrentValueSubject let protected: CurrentValueSubject let suspended: CurrentValueSubject @@ -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?) { diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 4a1237051..90d482bca 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -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) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index 6f324627b..cbc4fb8e2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -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()) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Field.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Field.swift index eb22d6adb..e16e3373d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Field.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Field.swift @@ -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 + } } }