Merge pull request #74 from tootsuite/feature/compose-poll

Add poll supports for status publish
This commit is contained in:
CMK 2021-03-25 15:43:51 +08:00 committed by GitHub
commit 91cd7322e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1463 additions and 291 deletions

View File

@ -207,6 +207,15 @@
"attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.",
"description_photo": "Describe photo for low vision people...",
"description_video": "Describe whats happening for low vision people..."
},
"poll": {
"duration_time": "Duration: %s",
"thirty_minutes": "30 minutes",
"one_hour": "1 Hour",
"six_hours": "6 Hours",
"one_day": "1 Day",
"three_days": "3 Days",
"seven_days": "7 Days"
}
}
}

View File

@ -123,6 +123,7 @@
DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; };
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; };
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; };
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; };
@ -187,6 +188,10 @@
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; };
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; };
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; };
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */; };
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */; };
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; };
DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -414,6 +419,7 @@
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = "<group>"; };
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -481,6 +487,10 @@
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; };
DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = "<group>"; };
DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = "<group>"; };
DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -665,6 +675,7 @@
2D152A8B25C295CC009AA50C /* StatusView.swift */,
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
);
path = Content;
sourceTree = "<group>";
@ -752,7 +763,6 @@
isa = PBXGroup;
children = (
DB45FB0425CA87B4005A8AC7 /* APIService */,
DB49A61925FF327D00B98345 /* EmojiService */,
DB9A489B26036E19008B817C /* MastodonAttachmentService */,
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
@ -843,6 +853,7 @@
DB9D6C1325E4F97A0051B173 /* Container */,
DBA9B90325F1D4420012E7B6 /* Control */,
2D152A8A25C295B8009AA50C /* Content */,
DB87D45C2609DE6600D12C0D /* TextField */,
DB1D187125EF5BBD003F1F23 /* TableView */,
2D7631A625C1533800929FB9 /* TableviewCell */,
);
@ -1153,10 +1164,21 @@
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */,
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */,
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */,
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
};
DB87D45C2609DE6600D12C0D /* TextField */ = {
isa = PBXGroup;
children = (
DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */,
);
path = TextField;
sourceTree = "<group>";
};
DB89B9EF25C10FD0008580ED /* CoreDataStack */ = {
isa = PBXGroup;
children = (
@ -1792,6 +1814,7 @@
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
@ -1806,6 +1829,7 @@
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
@ -1818,6 +1842,7 @@
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
@ -1868,6 +1893,7 @@
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
@ -1931,6 +1957,7 @@
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,

View File

@ -8,6 +8,7 @@
import Foundation
import MastodonSDK
/// Note: update Equatable when change case
enum CategoryPickerItem {
case all
case category(category: Mastodon.Entity.Category)

View File

@ -9,18 +9,24 @@ import Foundation
import Combine
import CoreData
/// Note: update Equatable when change case
enum ComposeStatusItem {
case replyTo(statusObjectID: NSManagedObjectID)
case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute)
case attachment(attachmentService: MastodonAttachmentService)
case pollOption(attribute: ComposePollOptionAttribute)
case pollOptionAppendEntry
case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute)
}
extension ComposeStatusItem: Equatable { }
extension ComposeStatusItem: Hashable { }
extension ComposeStatusItem {
final class ComposeStatusAttribute: Equatable, Hashable {
private let id = UUID()
let avatarURL = CurrentValueSubject<URL?, Never>(nil)
let displayName = CurrentValueSubject<String?, Never>(nil)
let username = CurrentValueSubject<String?, Never>(nil)
@ -38,3 +44,89 @@ extension ComposeStatusItem {
}
}
}
protocol ComposePollAttributeDelegate: class {
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?)
}
extension ComposeStatusItem {
final class ComposePollOptionAttribute: Equatable, Hashable {
private let id = UUID()
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposePollAttributeDelegate?
let option = CurrentValueSubject<String, Never>("")
init() {
option
.sink { [weak self] option in
guard let self = self else { return }
self.delegate?.composePollAttribute(self, pollOptionDidChange: option)
}
.store(in: &disposeBag)
}
deinit {
disposeBag.removeAll()
}
static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool {
return lhs.id == rhs.id &&
lhs.option.value == rhs.option.value
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension ComposeStatusItem {
final class ComposePollExpiresOptionAttribute: Equatable, Hashable {
private let id = UUID()
let expiresOption = CurrentValueSubject<ExpiresOption, Never>(.thirtyMinutes)
static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool {
return lhs.id == rhs.id &&
lhs.expiresOption.value == rhs.expiresOption.value
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
enum ExpiresOption: Equatable, Hashable, CaseIterable {
case thirtyMinutes
case oneHour
case sixHours
case oneDay
case threeDays
case sevenDays
var title: String {
switch self {
case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes
case .oneHour: return L10n.Scene.Compose.Poll.oneHour
case .sixHours: return L10n.Scene.Compose.Poll.sixHours
case .oneDay: return L10n.Scene.Compose.Poll.oneDay
case .threeDays: return L10n.Scene.Compose.Poll.threeDays
case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays
}
}
var seconds: Int {
switch self {
case .thirtyMinutes: return 60 * 30
case .oneHour: return 60 * 60 * 1
case .sixHours: return 60 * 60 * 6
case .oneDay: return 60 * 60 * 24
case .threeDays: return 60 * 60 * 24 * 3
case .sevenDays: return 60 * 60 * 24 * 7
}
}
}
}
}

View File

@ -8,6 +8,7 @@
import Foundation
import CoreData
/// Note: update Equatable when change case
enum PollItem {
case opion(objectID: NSManagedObjectID, attribute: Attribute)
}

View File

@ -16,6 +16,7 @@ enum ComposeStatusSection: Equatable, Hashable {
case repliedTo
case status
case attachment
case poll
}
extension ComposeStatusSection {
@ -33,7 +34,10 @@ extension ComposeStatusSection {
managedObjectContext: NSManagedObjectContext,
composeKind: ComposeKind,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
@ -54,11 +58,11 @@ extension ComposeStatusSection {
}
ComposeStatusSection.configure(cell: cell, attribute: attribute)
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
// self size input cell
cell.composeContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { text in
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
// bind input data
attribute.composeContent.value = text
@ -124,6 +128,30 @@ extension ComposeStatusSection {
}
.store(in: &cell.disposeBag)
return cell
case .pollOption(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell
cell.pollOptionView.optionTextField.text = attribute.option.value
cell.pollOption
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: attribute.option)
.store(in: &cell.disposeBag)
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
return cell
case .pollOptionAppendEntry:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate
return cell
case .pollExpiresOption(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
attribute.expiresOption
.receive(on: DispatchQueue.main)
.sink { expiresOption in
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
}
.store(in: &cell.disposeBag)
cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
return cell
}
}
}

View File

@ -38,7 +38,7 @@ extension PollSection {
pollOption option: PollOption,
pollItemAttribute attribute: PollItem.Attribute
) {
cell.optionLabel.text = option.title
cell.pollOptionView.optionTextField.text = option.title
configure(cell: cell, selectState: attribute.selectState)
configure(cell: cell, voteState: attribute.voteState)
cell.attribute = attribute
@ -52,35 +52,35 @@ extension PollSection {
static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) {
switch state {
case .none:
cell.checkmarkBackgroundView.isHidden = true
cell.checkmarkImageView.isHidden = true
cell.pollOptionView.checkmarkBackgroundView.isHidden = true
cell.pollOptionView.checkmarkImageView.isHidden = true
case .off:
cell.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
cell.checkmarkBackgroundView.layer.borderWidth = 1
cell.checkmarkBackgroundView.isHidden = false
cell.checkmarkImageView.isHidden = true
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
cell.pollOptionView.checkmarkImageView.isHidden = true
case .on:
cell.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
cell.checkmarkBackgroundView.layer.borderWidth = 0
cell.checkmarkBackgroundView.isHidden = false
cell.checkmarkImageView.isHidden = false
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
cell.pollOptionView.checkmarkImageView.isHidden = false
}
}
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
switch state {
case .hidden:
cell.optionPercentageLabel.isHidden = true
cell.voteProgressStripView.isHidden = true
cell.voteProgressStripView.setProgress(0.0, animated: false)
cell.pollOptionView.optionPercentageLabel.isHidden = true
cell.pollOptionView.voteProgressStripView.isHidden = true
cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false)
case .reveal(let voted, let percentage, let animated):
cell.optionPercentageLabel.isHidden = false
cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
cell.voteProgressStripView.isHidden = false
cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color
cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated)
cell.pollOptionView.optionPercentageLabel.isHidden = false
cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
cell.pollOptionView.voteProgressStripView.isHidden = false
cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color
cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated)
}
}

View File

@ -27,6 +27,7 @@ internal enum Asset {
}
internal enum Circles {
internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill")
internal static let plusCircle = ImageAsset(name: "Circles/plus.circle")
}
internal enum Colors {
internal enum Background {
@ -46,6 +47,7 @@ internal enum Asset {
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
}
internal enum Button {
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")

View File

@ -170,6 +170,24 @@ internal enum L10n {
/// Photo Library
internal static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary")
}
internal enum Poll {
/// Duration: %@
internal static func durationTime(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.Compose.Poll.DurationTime", String(describing: p1))
}
/// 1 Day
internal static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay")
/// 1 Hour
internal static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour")
/// 7 Days
internal static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays")
/// 6 Hours
internal static let sixHours = L10n.tr("Localizable", "Scene.Compose.Poll.SixHours")
/// 30 minutes
internal static let thirtyMinutes = L10n.tr("Localizable", "Scene.Compose.Poll.ThirtyMinutes")
/// 3 Days
internal static let threeDays = L10n.tr("Localizable", "Scene.Compose.Poll.ThreeDays")
}
internal enum Title {
/// New Post
internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost")

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "plus.circle.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,95 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.000000 0.000000 0.000000 scn
21.999905 0.000160 m
34.035152 0.000160 44.000000 9.986404 44.000000 22.000080 c
44.000000 34.035332 34.013599 44.000000 21.978350 44.000000 c
9.964656 44.000000 0.000000 34.035332 0.000000 22.000080 c
0.000000 9.986404 9.986255 0.000160 21.999905 0.000160 c
h
21.999905 3.666824 m
11.819542 3.666824 3.688203 11.819717 3.688203 22.000080 c
3.688203 32.180443 11.797986 40.333336 21.978350 40.333336 c
32.158710 40.333336 40.311611 32.180443 40.333256 22.000080 c
40.354897 11.819717 32.180267 3.666824 21.999905 3.666824 c
h
13.782296 20.188307 m
20.166574 20.188307 l
20.166574 13.760918 l
20.166574 12.682493 20.899923 11.949142 21.956793 11.949142 c
23.035217 11.949142 23.790121 12.682493 23.790121 13.760918 c
23.790121 20.188307 l
30.217514 20.188307 l
31.295938 20.188307 32.029289 20.921658 32.029289 21.978525 c
32.029289 23.056950 31.295938 23.811855 30.217514 23.811855 c
23.790121 23.811855 l
23.790121 30.196133 l
23.790121 31.317715 23.035217 32.051018 21.956793 32.051018 c
20.899923 32.051018 20.166574 31.296114 20.166574 30.196133 c
20.166574 23.811855 l
13.782296 23.811855 l
12.660716 23.811855 11.927410 23.056950 11.927410 21.978525 c
11.927410 20.921658 12.682316 20.188307 13.782296 20.188307 c
h
f
n
Q
endstream
endobj
3 0 obj
1347
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 44.000000 44.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001437 00000 n
0000001460 00000 n
0000001633 00000 n
0000001707 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1766
%%EOF

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "255",
"red" : "255"
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.169",
"green" : "0.141",
"red" : "0.125"
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "232",
"green" : "225",
"red" : "217"
"blue" : "0xE8",
"green" : "0xE1",
"red" : "0xD9"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.169",
"green" : "0.141",
"red" : "0.125"
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
}
},
"idiom" : "universal"

View File

@ -5,9 +5,27 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x43",
"green" : "0x36",
"red" : "0x32"
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
}
},
"idiom" : "universal"

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE8",
"green" : "0xE1",
"red" : "0xD9"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2B",
"green" : "0x23",
"red" : "0x1F"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -50,6 +50,13 @@ uploaded to Mastodon.";
"Scene.Compose.MediaSelection.Browse" = "Browse";
"Scene.Compose.MediaSelection.Camera" = "Take Photo";
"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library";
"Scene.Compose.Poll.DurationTime" = "Duration: %@";
"Scene.Compose.Poll.OneDay" = "1 Day";
"Scene.Compose.Poll.OneHour" = "1 Hour";
"Scene.Compose.Poll.SevenDays" = "7 Days";
"Scene.Compose.Poll.SixHours" = "6 Hours";
"Scene.Compose.Poll.ThirtyMinutes" = "30 minutes";
"Scene.Compose.Poll.ThreeDays" = "3 Days";
"Scene.Compose.Title.NewPost" = "New Post";
"Scene.Compose.Title.NewReply" = "New Reply";
"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email";

View File

@ -66,7 +66,7 @@ extension ComposeStatusContentCollectionViewCell {
textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20),
contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10),
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)

View File

@ -0,0 +1,74 @@
//
// ComposeStatusPollExpiresOptionCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import os.log
import UIKit
import Combine
protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: class {
func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption)
}
final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate?
let durationButton: UIButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12))
button.expandEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: -20, right: -20)
button.setTitle(L10n.Scene.Compose.Poll.durationTime(L10n.Scene.Compose.Poll.thirtyMinutes), for: .normal)
button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal)
return button
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusPollExpiresOptionCollectionViewCell {
private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption
private func _init() {
durationButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(durationButton)
NSLayoutConstraint.activate([
durationButton.topAnchor.constraint(equalTo: contentView.topAnchor),
durationButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin),
durationButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
let children = ExpiresOption.allCases.map { expiresOption -> UIAction in
UIAction(title: expiresOption.title, image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
guard let self = self else { return }
self.expiresOptionActionHandler(action, expiresOption: expiresOption)
}
}
durationButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
durationButton.showsMenuAsPrimaryAction = true
}
}
extension ComposeStatusPollExpiresOptionCollectionViewCell {
private func expiresOptionActionHandler(_ sender: UIAction, expiresOption: ExpiresOption) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, expiresOption.title)
delegate?.composeStatusPollExpiresOptionCollectionViewCell(self, didSelectExpiresOption: expiresOption)
}
}

View File

@ -0,0 +1,141 @@
//
// ComposeStatusPollOptionAppendEntryCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-23.
//
import os.log
import UIKit
protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: class {
func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell)
}
final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionViewCell {
let pollOptionView = PollOptionView()
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
}()
let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
weak var delegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate?
override var isHighlighted: Bool {
didSet {
pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color
pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return pollOptionView.frame.contains(point)
}
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusPollOptionAppendEntryCollectionViewCell {
private func _init() {
pollOptionView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(pollOptionView)
NSLayoutConstraint.activate([
pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor),
pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(reorderBarImageView)
NSLayoutConstraint.activate([
reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin),
reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
pollOptionView.checkmarkImageView.isHidden = true
pollOptionView.checkmarkBackgroundView.isHidden = true
pollOptionView.optionPercentageLabel.isHidden = true
pollOptionView.optionTextField.isHidden = true
pollOptionView.plusCircleImageView.isHidden = false
pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemBackground.color
setupBorderColor()
pollOptionView.addGestureRecognizer(singleTagGestureRecognizer)
singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionAppendEntryCollectionViewCell.singleTagGestureRecognizerHandler(_:)))
reorderBarImageView.isHidden = true
}
private func setupBorderColor() {
pollOptionView.roundedBackgroundView.layer.borderWidth = 1
pollOptionView.roundedBackgroundView.layer.borderColor = Asset.Colors.Background.secondarySystemBackground.color.cgColor
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setupBorderColor()
}
}
extension ComposeStatusPollOptionAppendEntryCollectionViewCell {
@objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(self)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ComposeStatusNewPollOptionCollectionViewCell_Previews: PreviewProvider {
static var controls: some View {
Group {
UIViewPreview() {
let cell = ComposeStatusPollOptionAppendEntryCollectionViewCell()
return cell
}
.previewLayout(.fixed(width: 375, height: 44 + 10))
}
}
static var previews: some View {
Group {
controls.colorScheme(.light)
controls.colorScheme(.dark)
}
.background(Color.gray)
}
}
#endif

View File

@ -0,0 +1,168 @@
//
// ComposeStatusPollOptionCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-23.
//
import os.log
import UIKit
import Combine
protocol ComposeStatusPollOptionCollectionViewCellDelegate: class {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?)
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField)
}
final class ComposeStatusPollOptionCollectionViewCell: UICollectionViewCell {
static let reorderHandlerImageLeadingMargin: CGFloat = 11
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposeStatusPollOptionCollectionViewCellDelegate?
let pollOptionView = PollOptionView()
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
}()
let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
private var pollOptionSubscription: AnyCancellable?
let pollOption = PassthroughSubject<String, Never>()
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return pollOptionView.frame.contains(point)
}
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
disposeBag.removeAll()
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusPollOptionCollectionViewCell {
private func _init() {
pollOptionView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(pollOptionView)
NSLayoutConstraint.activate([
pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor),
pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(reorderBarImageView)
NSLayoutConstraint.activate([
reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin),
reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
pollOptionView.checkmarkImageView.isHidden = true
pollOptionView.optionPercentageLabel.isHidden = true
pollOptionView.optionTextField.text = nil
pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
pollOptionView.checkmarkBackgroundView.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color
setupBorderColor()
pollOptionView.addGestureRecognizer(singleTagGestureRecognizer)
singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:)))
pollOptionSubscription = NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: pollOptionView.optionTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard let self = self else { return }
guard let textField = notification.object as? UITextField else { return }
self.pollOption.send(textField.text ?? "")
}
pollOptionView.optionTextField.deleteBackwardDelegate = self
pollOptionView.optionTextField.delegate = self
}
private func setupBorderColor() {
pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setupBorderColor()
}
}
extension ComposeStatusPollOptionCollectionViewCell {
@objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
pollOptionView.optionTextField.becomeFirstResponder()
}
}
// MARK: - DeleteBackwardResponseTextFieldDelegate
extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextFieldDelegate {
func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) {
delegate?.composeStatusPollOptionCollectionViewCell(self, textBeforeDeleteBackward: textBeforeDelete)
}
}
// MARK: - UITextFieldDelegate
extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
if textField === pollOptionView.optionTextField {
delegate?.composeStatusPollOptionCollectionViewCell(self, pollOptionTextFieldDidReturn: textField)
}
return true
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct ComposeStatusPollOptionCollectionViewCell_Previews: PreviewProvider {
static var controls: some View {
Group {
UIViewPreview() {
let cell = ComposeStatusPollOptionCollectionViewCell()
return cell
}
.previewLayout(.fixed(width: 375, height: 44 + 10))
}
}
static var previews: some View {
Group {
controls.colorScheme(.light)
controls.colorScheme(.dark)
}
.background(Color.gray)
}
}
#endif

View File

@ -45,6 +45,9 @@ final class ComposeViewController: UIViewController, NeedsDependency {
collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self))
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color
return collectionView
}()
@ -147,15 +150,17 @@ extension ComposeViewController {
])
collectionView.delegate = self
// Note: do not allow reorder due to the images display order following the upload time
// let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
// collectionView.addGestureRecognizer(longPressReorderGesture)
viewModel.setupDiffableDataSource(
for: collectionView,
dependency: self,
textEditorViewTextAttributesDelegate: self,
composeStatusAttachmentTableViewCellDelegate: self
composeStatusAttachmentTableViewCellDelegate: self,
composeStatusPollOptionCollectionViewCellDelegate: self,
composeStatusNewPollOptionCollectionViewCellDelegate: self,
composeStatusPollExpiresOptionCollectionViewCellDelegate: self
)
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
collectionView.addGestureRecognizer(longPressReorderGesture)
// respond scrollView overlap change
view.layoutIfNeeded()
@ -205,6 +210,16 @@ extension ComposeViewController {
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: publishBarButtonItem)
.store(in: &disposeBag)
viewModel.isMediaToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.mediaButton)
.store(in: &disposeBag)
viewModel.isPollToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.pollButton)
.store(in: &disposeBag)
// bind custom emojis
viewModel.customEmojiViewModel
@ -268,6 +283,57 @@ extension ComposeViewController {
textEditorView()?.isEditing = true
}
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
guard case .pollOption = item else { return nil }
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
guard let indexPath = diffableDataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else {
return nil
}
return cell
}
private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
let firstPollItem = items.first { item -> Bool in
guard case .pollOption = item else { return false }
return true
}
guard let item = firstPollItem else {
return nil
}
return pollOptionCollectionViewCell(of: item)
}
private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
let lastPollItem = items.last { item -> Bool in
guard case .pollOption = item else { return false }
return true
}
guard let item = lastPollItem else {
return nil
}
return pollOptionCollectionViewCell(of: item)
}
private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() {
guard let cell = firstPollOptionCollectionViewCell() else { return }
cell.pollOptionView.optionTextField.becomeFirstResponder()
}
private func markLastPollOptionCollectionViewCellBecomeFirstResponser() {
guard let cell = lastPollOptionCollectionViewCell() else { return }
cell.pollOptionView.optionTextField.becomeFirstResponder()
}
private func showDismissConfirmAlertController() {
let alertController = UIAlertController(
title: L10n.Common.Alerts.DiscardPostContent.title,
@ -322,13 +388,20 @@ extension ComposeViewController {
dismiss(animated: true, completion: nil)
}
/* Do not allow reorder image due to image display order following the update time
// seealso: ComposeViewModel.setupDiffableDataSource()
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
switch(sender.state) {
case .began:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)) else {
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else {
break
}
// check if pressing reorder bar no not
let locationInCell = sender.location(in: cell)
guard cell.reorderBarImageView.frame.contains(locationInCell) else {
return
}
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
@ -336,19 +409,20 @@ extension ComposeViewController {
break
}
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
case .attachment = item else {
case .pollOption = item else {
collectionView.cancelInteractiveMovement()
return
}
collectionView.updateInteractiveMovementTargetPosition(sender.location(in: collectionView))
var position = sender.location(in: collectionView)
position.x = collectionView.frame.width * 0.5
collectionView.updateInteractiveMovementTargetPosition(position)
case .ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
*/
}
@ -490,7 +564,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
extension ComposeViewController: ComposeToolbarViewDelegate {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, mediaSelectionType.rawValue)
switch mediaSelectionType {
case .photoLibrary:
present(imagePicker, animated: true, completion: nil)
@ -501,20 +574,31 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
}
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) {
viewModel.isPollComposing.value.toggle()
// setup initial poll option if needs
if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty {
viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()]
}
if viewModel.isPollComposing.value {
// Magic RunLoop
DispatchQueue.main.async {
self.markFirstPollOptionCollectionViewCellBecomeFirstResponser()
}
} else {
markTextEditorViewBecomeFirstResponser()
}
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) {
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) {
}
}
@ -554,7 +638,6 @@ extension ComposeViewController: PHPickerViewControllerDelegate {
pickerResult: result,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
service.delegate = viewModel
return service
}
viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices
@ -574,7 +657,6 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC
image: image,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
attachmentService.delegate = viewModel
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
}
@ -598,7 +680,6 @@ extension ComposeViewController: UIDocumentPickerDelegate {
imageData: imageData,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
attachmentService.delegate = viewModel
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
@ -622,3 +703,95 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
}
}
// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate {
// handle delete backward event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
guard (text ?? "").isEmpty else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .pollOption(attribute) = item else { return }
var pollAttributes = viewModel.pollOptionAttributes.value
guard let index = pollAttributes.firstIndex(of: attribute) else { return }
// mark previous (fallback to next) item of removed middle poll option become first responder
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
if let indexOfItem = pollItems.firstIndex(of: item), index > 0 {
func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
guard index > 0 else { return nil }
let indexBeforeRemoved = pollItems.index(before: indexOfItem)
let itemBeforeRemoved = pollItems[indexBeforeRemoved]
return pollOptionCollectionViewCell(of: itemBeforeRemoved)
}
func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
guard index < pollItems.count - 1 else { return nil }
let indexAfterRemoved = pollItems.index(after: index)
let itemAfterRemoved = pollItems[indexAfterRemoved]
return pollOptionCollectionViewCell(of: itemAfterRemoved)
}
var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved()
if cell == nil {
cell = cellAfterRemoved()
}
cell?.pollOptionView.optionTextField.becomeFirstResponder()
}
guard pollAttributes.count > 2 else {
return
}
pollAttributes.remove(at: index)
// update data source
viewModel.pollOptionAttributes.value = pollAttributes
}
// handle keyboard return event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return }
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in
guard case .pollOption = item else { return false }
return true
}
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard let index = pollItems.firstIndex(of: item) else { return }
if index == pollItems.count - 1 {
// is the last
viewModel.createNewPollOptionIfPossible()
DispatchQueue.main.async {
self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
}
} else {
// not the last
let indexAfter = pollItems.index(after: index)
let itemAfter = pollItems[indexAfter]
let cell = pollOptionCollectionViewCell(of: itemAfter)
cell?.pollOptionView.optionTextField.becomeFirstResponder()
}
}
}
// MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate {
func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) {
viewModel.createNewPollOptionIfPossible()
DispatchQueue.main.async {
self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
}
}
}
// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate {
func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) {
viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption
}
}

View File

@ -14,7 +14,10 @@ extension ComposeViewModel {
for collectionView: UICollectionView,
dependency: NeedsDependency,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) {
let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource(
for: collectionView,
@ -22,33 +25,36 @@ extension ComposeViewModel {
managedObjectContext: context.managedObjectContext,
composeKind: composeKind,
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate
)
// Note: do not allow reorder due to the images display order following the upload time
// diffableDataSource.reorderingHandlers.canReorderItem = { item in
// switch item {
// case .attachment: return true
// default: return false
// }
//
// }
// diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in
// guard let self = self else { return }
//
// let items = transaction.finalSnapshot.itemIdentifiers
// var attachmentServices: [MastodonAttachmentService] = []
// for item in items {
// guard case let .attachment(attachmentService) = item else { continue }
// attachmentServices.append(attachmentService)
// }
// self.attachmentServices.value = attachmentServices
// }
//
diffableDataSource.reorderingHandlers.canReorderItem = { item in
switch item {
case .pollOption: return true
default: return false
}
}
// update reordered data source
diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in
guard let self = self else { return }
let items = transaction.finalSnapshot.itemIdentifiers
var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = []
for item in items {
guard case let .pollOption(attribute) = item else { continue }
pollOptionAttributes.append(attribute)
}
self.pollOptionAttributes.value = pollOptionAttributes
}
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
snapshot.appendSections([.repliedTo, .status, .attachment])
snapshot.appendSections([.repliedTo, .status, .attachment, .poll])
switch composeKind {
case .reply(let statusObjectID):
snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo)

View File

@ -53,6 +53,15 @@ extension ComposeViewModel.PublishState {
let mediaIDs = attachmentServices.compactMap { attachmentService in
attachmentService.attachment.value?.id
}
let pollOptions: [String]? = {
guard viewModel.isPollComposing.value else { return nil }
return viewModel.pollOptionAttributes.value.map { attribute in attribute.option.value }
}()
let pollExpiresIn: Int? = {
guard viewModel.isPollComposing.value else { return nil }
return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
}()
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
for attachmentService in attachmentServices {
@ -81,7 +90,9 @@ extension ComposeViewModel.PublishState {
.flatMap { attachments -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
let query = Mastodon.API.Statuses.PublishStatusQuery(
status: viewModel.composeStatusAttribute.composeContent.value,
mediaIDs: mediaIDs
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn
)
return viewModel.context.apiService.publishStatus(
domain: domain,

View File

@ -19,6 +19,7 @@ final class ComposeViewModel {
let context: AppContext
let composeKind: ComposeStatusSection.ComposeKind
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
let isPollComposing = CurrentValueSubject<Bool, Never>(false)
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
@ -41,6 +42,8 @@ final class ComposeViewModel {
let title: CurrentValueSubject<String, Never>
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
let isMediaToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
// custom emojis
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
@ -48,6 +51,10 @@ final class ComposeViewModel {
// attachment
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
// polls
let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([])
let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute()
init(
context: AppContext,
composeKind: ComposeStatusSection.ComposeKind
@ -98,19 +105,48 @@ final class ComposeViewModel {
.map { services in
services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish }
}
Publishers.CombineLatest4(
let isPollAttributeAllValid = pollOptionAttributes
.map { pollAttributes in
pollAttributes.allSatisfy { attribute -> Bool in
!attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty.eraseToAnyPublisher(),
isComposeContentValid.eraseToAnyPublisher(),
isMediaEmpty.eraseToAnyPublisher(),
isMediaUploadAllSuccess.eraseToAnyPublisher()
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess in
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
return isComposeContentValid && !isComposeContentEmpty
} else {
return isComposeContentValid && isMediaUploadAllSuccess
}
}
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
isComposeContentEmpty.eraseToAnyPublisher(),
isComposeContentValid.eraseToAnyPublisher(),
isPollComposing.eraseToAnyPublisher(),
isPollAttributeAllValid.eraseToAnyPublisher()
)
.map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in
if isPollComposing {
return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid
} else {
return isComposeContentValid && !isComposeContentEmpty
}
}
.eraseToAnyPublisher()
Publishers.CombineLatest(
isPublishBarButtonItemEnabledPrecondition1,
isPublishBarButtonItemEnabledPrecondition2
)
.map { $0 && $1 }
.assign(to: \.value, on: isPublishBarButtonItemEnabled)
.store(in: &disposeBag)
@ -137,49 +173,108 @@ final class ComposeViewModel {
}
.store(in: &disposeBag)
// bind snapshot and drive service upload state
attachmentServices
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment))
var items: [ComposeStatusItem] = []
for attachmentService in attachmentServices {
let item = ComposeStatusItem.attachment(attachmentService: attachmentService)
items.append(item)
// bind snapshot
Publishers.CombineLatest3(
attachmentServices.eraseToAnyPublisher(),
isPollComposing.eraseToAnyPublisher(),
pollOptionAttributes.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices, isPollComposing, pollAttributes in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment))
var attachmentItems: [ComposeStatusItem] = []
for attachmentService in attachmentServices {
let item = ComposeStatusItem.attachment(attachmentService: attachmentService)
attachmentItems.append(item)
}
snapshot.appendItems(attachmentItems, toSection: .attachment)
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll))
if isPollComposing {
var pollItems: [ComposeStatusItem] = []
for pollAttribute in pollAttributes {
let item = ComposeStatusItem.pollOption(attribute: pollAttribute)
pollItems.append(item)
}
snapshot.appendItems(items, toSection: .attachment)
diffableDataSource.apply(snapshot)
// make image upload in the queue
for attachmentService in attachmentServices {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
if currentState is MastodonAttachmentService.UploadState.Fail {
continue
}
if currentState is MastodonAttachmentService.UploadState.Finish {
continue
}
if currentState is MastodonAttachmentService.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is MastodonAttachmentService.UploadState.Initial {
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
break
}
snapshot.appendItems(pollItems, toSection: .poll)
if pollAttributes.count < 4 {
snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll)
}
snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll)
}
diffableDataSource.apply(snapshot)
// drive service upload state
// make image upload in the queue
for attachmentService in attachmentServices {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
if currentState is MastodonAttachmentService.UploadState.Fail {
continue
}
if currentState is MastodonAttachmentService.UploadState.Finish {
continue
}
if currentState is MastodonAttachmentService.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is MastodonAttachmentService.UploadState.Initial {
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
break
}
}
}
.store(in: &disposeBag)
// bind delegate
attachmentServices
.sink { [weak self] attachmentServices in
guard let self = self else { return }
attachmentServices.forEach { $0.delegate = self }
}
.store(in: &disposeBag)
pollOptionAttributes
.sink { [weak self] pollAttributes in
guard let self = self else { return }
pollAttributes.forEach { $0.delegate = self }
}
.store(in: &disposeBag)
// bind compose toolbar UI state
Publishers.CombineLatest(
isPollComposing.eraseToAnyPublisher(),
attachmentServices.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] isPollComposing, attachmentServices in
guard let self = self else { return }
let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4
let shouldPollDisable = attachmentServices.count > 0
self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
})
.store(in: &disposeBag)
}
}
extension ComposeViewModel {
func createNewPollOptionIfPossible() {
guard pollOptionAttributes.value.count < 4 else { return }
let attribute = ComposeStatusItem.ComposePollOptionAttribute()
pollOptionAttributes.value = pollOptionAttributes.value + [attribute]
}
}
// MARK: - MastodonAttachmentServiceDelegate
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
@ -187,3 +282,11 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate {
attachmentServices.value = attachmentServices.value
}
}
// MARK: - ComposePollAttributeDelegate
extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) {
// trigger update
pollOptionAttributes.value = pollOptionAttributes.value
}
}

View File

@ -5,14 +5,15 @@
// Created by MainasuK Cirno on 2021-3-12.
//
import os.log
import UIKit
protocol ComposeToolbarViewDelegate: class {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton)
}
final class ComposeToolbarView: UIView {
@ -102,10 +103,10 @@ extension ComposeToolbarView {
mediaButton.menu = createMediaContextMenu()
mediaButton.showsMenuAsPrimaryAction = true
pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside)
emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside)
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside)
visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.locationButtonDidPressed(_:)), for: .touchUpInside)
pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside)
emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside)
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside)
visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.visibilityButtonDidPressed(_:)), for: .touchUpInside)
}
}
@ -131,18 +132,21 @@ extension ComposeToolbarView {
var children: [UIMenuElement] = []
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function)
self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary)
}
children.append(photoLibraryAction)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function)
self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera)
})
children.append(cameraAction)
}
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function)
self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse)
}
children.append(browseAction)
@ -155,20 +159,24 @@ extension ComposeToolbarView {
extension ComposeToolbarView {
@objc private func gifButtonDidPressed(_ sender: UIButton) {
delegate?.composeToolbarView(self, gifButtonDidPressed: sender)
@objc private func pollButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, pollButtonDidPressed: sender)
}
@objc private func atButtonDidPressed(_ sender: UIButton) {
delegate?.composeToolbarView(self, atButtonDidPressed: sender)
@objc private func emojiButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, emojiButtonDidPressed: sender)
}
@objc private func topicButtonDidPressed(_ sender: UIButton) {
delegate?.composeToolbarView(self, topicButtonDidPressed: sender)
@objc private func contentWarningButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender)
}
@objc private func locationButtonDidPressed(_ sender: UIButton) {
delegate?.composeToolbarView(self, locationButtonDidPressed: sender)
@objc private func visibilityButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, visibilityButtonDidPressed: sender)
}
}

View File

@ -0,0 +1,201 @@
//
// PollOptionView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-23.
//
import UIKit
import Combine
final class PollOptionView: UIView {
static let height: CGFloat = optionHeight + 2 * verticalMargin
static let optionHeight: CGFloat = 44
static let verticalMargin: CGFloat = 5
static let checkmarkImageSize = CGSize(width: 26, height: 26)
static let checkmarkBackgroundLeadingMargin: CGFloat = 9
private var viewStateDisposeBag = Set<AnyCancellable>()
let roundedBackgroundView = UIView()
let voteProgressStripView: StripProgressView = {
let view = StripProgressView()
view.tintColor = Asset.Colors.Background.Poll.highlight.color
return view
}()
let checkmarkBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
return view
}()
let checkmarkImageView: UIImageView = {
let imageView = UIImageView()
let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))!
imageView.image = image.withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Button.normal.color
return imageView
}()
let plusCircleImageView: UIImageView = {
let imageView = UIImageView()
let image = Asset.Circles.plusCircle.image
imageView.image = image.withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Button.normal.color
return imageView
}()
let optionTextField: DeleteBackwardResponseTextField = {
let textField = DeleteBackwardResponseTextField()
textField.font = .systemFont(ofSize: 15, weight: .medium)
textField.textColor = Asset.Colors.Label.primary.color
textField.text = "Option"
textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right
return textField
}()
let optionLabelMiddlePaddingView = UIView()
let optionPercentageLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 13, weight: .regular)
label.textColor = Asset.Colors.Label.primary.color
label.text = "50%"
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension PollOptionView {
private func _init() {
roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(roundedBackgroundView)
NSLayoutConstraint.activate([
roundedBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 5),
roundedBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
roundedBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5),
roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.optionHeight).priority(.defaultHigh),
])
voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(voteProgressStripView)
NSLayoutConstraint.activate([
voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor),
voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor),
voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor),
voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor),
])
checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(checkmarkBackgroundView)
NSLayoutConstraint.activate([
checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9),
checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin),
roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9),
checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.width).priority(.required - 1),
checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.height).priority(.required - 1),
])
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
checkmarkBackgroundView.addSubview(checkmarkImageView)
NSLayoutConstraint.activate([
checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5),
checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5),
checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5),
checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5),
])
plusCircleImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(plusCircleImageView)
NSLayoutConstraint.activate([
plusCircleImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor),
plusCircleImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor),
plusCircleImageView.trailingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor),
plusCircleImageView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor),
])
optionTextField.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionTextField)
NSLayoutConstraint.activate([
optionTextField.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14),
optionTextField.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
optionTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionLabelMiddlePaddingView)
NSLayoutConstraint.activate([
optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionTextField.trailingAnchor),
optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow),
])
optionLabelMiddlePaddingView.setContentHuggingPriority(.required - 1, for: .horizontal)
optionLabelMiddlePaddingView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionPercentageLabel)
NSLayoutConstraint.activate([
optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor),
roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18),
optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
])
optionPercentageLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
plusCircleImageView.isHidden = true
}
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
}
}
extension PollOptionView {
private func updateCornerRadius() {
roundedBackgroundView.layer.masksToBounds = true
roundedBackgroundView.layer.cornerRadius = PollOptionView.optionHeight * 0.5
roundedBackgroundView.layer.cornerCurve = .circular
checkmarkBackgroundView.layer.masksToBounds = true
checkmarkBackgroundView.layer.cornerRadius = PollOptionView.checkmarkImageSize.width * 0.5
checkmarkBackgroundView.layer.cornerCurve = .circular
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct PollOptionView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
PollOptionView()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
}
#endif

View File

@ -10,54 +10,8 @@ import Combine
final class PollOptionTableViewCell: UITableViewCell {
static let height: CGFloat = optionHeight + 2 * verticalMargin
static let optionHeight: CGFloat = 44
static let verticalMargin: CGFloat = 5
static let checkmarkImageSize = CGSize(width: 26, height: 26)
private var viewStateDisposeBag = Set<AnyCancellable>()
let pollOptionView = PollOptionView()
var attribute: PollItem.Attribute?
let roundedBackgroundView = UIView()
let voteProgressStripView: StripProgressView = {
let view = StripProgressView()
view.tintColor = Asset.Colors.Background.Poll.highlight.color
return view
}()
let checkmarkBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
return view
}()
let checkmarkImageView: UIView = {
let imageView = UIImageView()
let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))!
imageView.image = image.withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Button.normal.color
return imageView
}()
let optionLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .medium)
label.textColor = Asset.Colors.Label.primary.color
label.text = "Option"
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right
return label
}()
let optionLabelMiddlePaddingView = UIView()
let optionPercentageLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 13, weight: .regular)
label.textColor = Asset.Colors.Label.primary.color
label.text = "50%"
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -76,7 +30,7 @@ final class PollOptionTableViewCell: UITableViewCell {
switch voteState {
case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color
self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
case .reveal:
break
}
@ -89,7 +43,7 @@ final class PollOptionTableViewCell: UITableViewCell {
switch voteState {
case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color
self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
case .reveal:
break
}
@ -102,125 +56,55 @@ extension PollOptionTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
pollOptionView.optionTextField.isUserInteractionEnabled = false
roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(roundedBackgroundView)
pollOptionView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(pollOptionView)
NSLayoutConstraint.activate([
roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5),
roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh),
pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor),
pollOptionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
pollOptionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(voteProgressStripView)
NSLayoutConstraint.activate([
voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor),
voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor),
voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor),
voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor),
])
checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(checkmarkBackgroundView)
NSLayoutConstraint.activate([
checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9),
checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9),
roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9),
checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh),
checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh),
])
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
checkmarkBackgroundView.addSubview(checkmarkImageView)
NSLayoutConstraint.activate([
checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5),
checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5),
checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5),
checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5),
])
optionLabel.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionLabel)
NSLayoutConstraint.activate([
optionLabel.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14),
optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
])
optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionLabelMiddlePaddingView)
NSLayoutConstraint.activate([
optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor),
optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow),
])
optionLabelMiddlePaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal)
optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionPercentageLabel)
NSLayoutConstraint.activate([
optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor),
roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18),
optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
])
optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal)
optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
}
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
updateTextAppearance()
}
private func updateCornerRadius() {
roundedBackgroundView.layer.masksToBounds = true
roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5
roundedBackgroundView.layer.cornerCurve = .circular
checkmarkBackgroundView.layer.masksToBounds = true
checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5
checkmarkBackgroundView.layer.cornerCurve = .circular
}
func updateTextAppearance() {
guard let voteState = attribute?.voteState else {
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow()
pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color
pollOptionView.optionTextField.layer.removeShadow()
return
}
switch voteState {
case .hidden:
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow()
pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color
pollOptionView.optionTextField.layer.removeShadow()
case .reveal(_, let percentage, _):
if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX {
optionLabel.textColor = .white
optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)
if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.minX {
pollOptionView.optionTextField.textColor = .white
pollOptionView.optionTextField.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)
} else {
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow()
pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color
pollOptionView.optionTextField.layer.removeShadow()
}
if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.maxX {
optionPercentageLabel.textColor = .white
optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)
if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.maxX {
pollOptionView.optionPercentageLabel.textColor = .white
pollOptionView.optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)
} else {
optionPercentageLabel.textColor = Asset.Colors.Label.primary.color
optionPercentageLabel.layer.removeShadow()
pollOptionView.optionPercentageLabel.textColor = Asset.Colors.Label.primary.color
pollOptionView.optionPercentageLabel.layer.removeShadow()
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateTextAppearance()
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI

View File

@ -0,0 +1,24 @@
//
// DeleteBackwardResponseTextField.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-23.
//
import UIKit
protocol DeleteBackwardResponseTextFieldDelegate: class {
func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?)
}
final class DeleteBackwardResponseTextField: UITextField {
weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate?
override func deleteBackward() {
let text = self.text
super.deleteBackward()
deleteBackwardDelegate?.deleteBackwardResponseTextField(self, textBeforeDelete: text)
}
}

View File

@ -96,15 +96,34 @@ extension Mastodon.API.Statuses {
public struct PublishStatusQuery: Codable, PostQuery {
public let status: String?
public let mediaIDs: [String]?
public let pollOptions: [String]?
public let pollExpiresIn: Int?
enum CodingKeys: String, CodingKey {
case status
case mediaIDs = "media_ids"
}
public init(status: String?, mediaIDs: [String]?) {
public init(status: String?, mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?) {
self.status = status
self.mediaIDs = mediaIDs
self.pollOptions = pollOptions
self.pollExpiresIn = pollExpiresIn
}
var contentType: String? {
return Self.multipartContentType()
}
var body: Data? {
var data = Data()
status.flatMap { data.append(Data.multipart(key: "status", value: $0)) }
for mediaID in mediaIDs ?? [] {
data.append(Data.multipart(key: "media_ids[]", value: mediaID))
}
for pollOption in pollOptions ?? [] {
data.append(Data.multipart(key: "poll[options][]", value: pollOption))
}
pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) }
data.append(Data.multipartEnd())
return data
}
}

View File

@ -35,3 +35,12 @@ extension String: MultipartFormValue {
var multipartContentType: String? { return nil }
var multipartFilename: String? { return nil }
}
extension Int: MultipartFormValue {
var multipartValue: Data {
return String(self).data(using: .utf8)!
}
var multipartContentType: String? { return nil }
var multipartFilename: String? { return nil }
}