feat: make poll cell label appearance update according to the underneath background

This commit is contained in:
CMK 2021-03-03 19:34:29 +08:00
parent 30c035e09a
commit 028f3a9404
19 changed files with 494 additions and 118 deletions

View File

@ -84,6 +84,7 @@
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/> <relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/> <relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
<relationship name="votePollOptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/> <relationship name="votePollOptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity> </entity>
<entity name="Mention" representedClassName=".Mention" syncable="YES"> <entity name="Mention" representedClassName=".Mention" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
@ -105,6 +106,7 @@
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/> <relationship name="options" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="poll" inverseEntity="Toot"/> <relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="poll" inverseEntity="Toot"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity> </entity>
<entity name="PollOption" representedClassName=".PollOption" syncable="YES"> <entity name="PollOption" representedClassName=".PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -166,9 +168,9 @@
<element name="History" positionX="27" positionY="126" width="128" height="119"/> <element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/> <element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/> <element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="299"/> <element name="MastodonUser" positionX="0" positionY="0" width="128" height="314"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/> <element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="179"/> <element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/> <element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/> <element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Toot" positionX="0" positionY="0" width="128" height="539"/> <element name="Toot" positionX="0" positionY="0" width="128" height="539"/>

View File

@ -38,6 +38,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var muted: Set<Toot>? @NSManaged public private(set) var muted: Set<Toot>?
@NSManaged public private(set) var bookmarked: Set<Toot>? @NSManaged public private(set) var bookmarked: Set<Toot>?
@NSManaged public private(set) var votePollOptions: Set<PollOption>? @NSManaged public private(set) var votePollOptions: Set<PollOption>?
@NSManaged public private(set) var votePolls: Set<Poll>?
} }

View File

@ -26,6 +26,9 @@ public final class Poll: NSManagedObject {
// one-to-many relationship // one-to-many relationship
@NSManaged public private(set) var options: Set<PollOption> @NSManaged public private(set) var options: Set<PollOption>
// many-to-many relationship
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
} }
extension Poll { extension Poll {
@ -39,6 +42,7 @@ extension Poll {
public static func insert( public static func insert(
into context: NSManagedObjectContext, into context: NSManagedObjectContext,
property: Property, property: Property,
votedBy: MastodonUser?,
options: [PollOption] options: [PollOption]
) -> Poll { ) -> Poll {
let poll: Poll = context.insertObject() let poll: Poll = context.insertObject()
@ -50,7 +54,12 @@ extension Poll {
poll.votesCount = property.votesCount poll.votesCount = property.votesCount
poll.votersCount = property.votersCount poll.votersCount = property.votersCount
poll.updatedAt = property.networkDate poll.updatedAt = property.networkDate
if let votedBy = votedBy {
poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy)
}
poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options)
return poll return poll
@ -80,6 +89,18 @@ extension Poll {
} }
} }
public func update(voted: Bool, by: MastodonUser) {
if voted {
if !(votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by)
}
} else {
if (votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by)
}
}
}
public func didUpdate(at networkDate: Date) { public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate self.updatedAt = networkDate
} }

View File

@ -56,9 +56,15 @@ extension PollOption {
} }
} }
public func update(votedBy: MastodonUser) { public func update(voted: Bool, by: MastodonUser) {
if !(self.votedBy ?? Set()).contains(votedBy) { if voted {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) if !(self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by)
}
} else {
if !(self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by)
}
} }
} }

View File

@ -69,7 +69,8 @@
"single": "%d voter", "single": "%d voter",
"multiple": "%d voters", "multiple": "%d voters",
}, },
"time_left": "%s left" "time_left": "%s left",
"closed": "Closed"
} }
}, },
"timeline": { "timeline": {

View File

@ -129,6 +129,7 @@
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; };
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; };
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; };
DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */; };
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; };
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
@ -357,6 +358,7 @@
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.swift; sourceTree = "<group>"; };
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = "<group>"; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = "<group>"; };
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; };
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
@ -525,6 +527,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2D152A8B25C295CC009AA50C /* StatusView.swift */, 2D152A8B25C295CC009AA50C /* StatusView.swift */,
DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */,
); );
path = Content; path = Content;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1520,6 +1523,7 @@
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */,
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,

View File

@ -15,20 +15,34 @@ enum PollItem {
extension PollItem { extension PollItem {
class Attribute: Hashable { class Attribute: Hashable {
// var pollVotable: Bool
var isOptionVoted: Bool
init(isOptionVoted: Bool) { enum SelectState: Equatable, Hashable {
// self.pollVotable = pollVotable case none
self.isOptionVoted = isOptionVoted case off
case on
}
enum VoteState: Equatable, Hashable {
case hidden
case reveal(voted: Bool, percentage: Double)
}
var selectState: SelectState
var voteState: VoteState
init(selectState: SelectState, voteState: VoteState) {
self.selectState = selectState
self.voteState = voteState
} }
static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool {
return lhs.isOptionVoted == rhs.isOptionVoted return lhs.selectState == rhs.selectState &&
lhs.voteState == rhs.voteState
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(isOptionVoted) hasher.combine(selectState)
hasher.combine(voteState)
} }
} }
} }

View File

@ -39,6 +39,49 @@ extension PollSection {
itemAttribute: PollItem.Attribute itemAttribute: PollItem.Attribute
) { ) {
cell.optionLabel.text = pollOption.title cell.optionLabel.text = pollOption.title
cell.configure(state: itemAttribute.isOptionVoted ? .on : .off) configure(cell: cell, selectState: itemAttribute.selectState)
configure(cell: cell, voteState: itemAttribute.voteState)
} }
} }
extension PollSection {
static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) {
switch state {
case .none:
cell.checkmarkBackgroundView.isHidden = true
cell.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
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.selectState = state
}
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
switch state {
case .hidden:
cell.optionPercentageLabel.isHidden = true
case .reveal(let voted, let percentage):
cell.optionPercentageLabel.isHidden = false
cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color
cell.voteProgressStripView.progress.send(CGFloat(percentage))
}
cell.voteState = state
cell.layoutIfNeeded()
cell.updateTextAppearance()
}
}

View File

@ -66,6 +66,9 @@ extension StatusSection {
} }
} }
} }
}
extension StatusSection {
static func configure( static func configure(
cell: StatusTableViewCell, cell: StatusTableViewCell,
@ -155,52 +158,20 @@ extension StatusSection {
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// set poll // set poll
if let poll = (toot.reblog ?? toot).poll { let poll = (toot.reblog ?? toot).poll
cell.statusView.pollTableView.isHidden = false configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID)
cell.statusView.pollStatusStackView.isHidden = false if let poll = poll {
cell.statusView.pollVoteButton.isHidden = !poll.multiple ManagedObjectObserver.observe(object: poll)
cell.statusView.pollVoteCountLabel.text = { .sink { _ in
if poll.multiple { // do nothing
let count = poll.votersCount?.intValue ?? 0 } receiveValue: { change in
if count > 1 { guard case let .update(object) = change.changeType,
return L10n.Common.Controls.Status.Poll.VoterCount.single(count) let newPoll = object as? Poll else { return }
} else { StatusSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID)
return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count)
}
} else {
let count = poll.votesCount.intValue
if count > 1 {
return L10n.Common.Controls.Status.Poll.VoteCount.single(count)
} else {
return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count)
}
} }
}() .store(in: &cell.disposeBag)
let managedObjectContext = toot.managedObjectContext!
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
for: cell.statusView.pollTableView,
managedObjectContext: managedObjectContext
)
var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>()
snapshot.appendSections([.main])
let pollItems = poll.options
.sorted(by: { $0.index.intValue < $1.index.intValue })
.map { option -> PollItem in
let isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID)
let attribute = PollItem.Attribute(isOptionVoted: isVoted)
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
return option
}
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
} else {
cell.statusView.pollTableView.isHidden = true
cell.statusView.pollStatusStackView.isHidden = true
cell.statusView.pollVoteButton.isHidden = true
} }
// toolbar // toolbar
let replyCountTitle: String = { let replyCountTitle: String = {
let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0
@ -244,6 +215,104 @@ extension StatusSection {
} }
.store(in: &cell.disposeBag) .store(in: &cell.disposeBag)
} }
static func configure(
cell: StatusTableViewCell,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
poll: Poll?,
requestUserID: String
) {
guard let poll = poll,
let managedObjectContext = poll.managedObjectContext else {
cell.statusView.pollTableView.isHidden = true
cell.statusView.pollStatusStackView.isHidden = true
cell.statusView.pollVoteButton.isHidden = true
return
}
cell.statusView.pollTableView.isHidden = false
cell.statusView.pollStatusStackView.isHidden = false
cell.statusView.pollVoteButton.isHidden = !poll.multiple
cell.statusView.pollVoteCountLabel.text = {
if poll.multiple {
let count = poll.votersCount?.intValue ?? 0
if count > 1 {
return L10n.Common.Controls.Status.Poll.VoterCount.single(count)
} else {
return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count)
}
} else {
let count = poll.votesCount.intValue
if count > 1 {
return L10n.Common.Controls.Status.Poll.VoteCount.single(count)
} else {
return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count)
}
}
}()
if poll.expired {
cell.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
} else if let expiresAt = poll.expiresAt {
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
cell.pollCountdownSubscription = timestampUpdatePublisher
.sink { _ in
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
}
} else {
assertionFailure()
cell.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = "-"
}
cell.statusView.pollTableView.allowsSelection = !poll.expired
cell.statusView.pollTableView.allowsMultipleSelection = poll.multiple
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
for: cell.statusView.pollTableView,
managedObjectContext: managedObjectContext
)
var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>()
snapshot.appendSections([.main])
let votedOptions = poll.options.filter { option in
(option.votedBy ?? Set()).map { $0.id }.contains(requestUserID)
}
let isPollVoted = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID)
let pollItems = poll.options
.sorted(by: { $0.index.intValue < $1.index.intValue })
.map { option -> PollItem in
let attribute: PollItem.Attribute = {
let selectState: PollItem.Attribute.SelectState = {
if isPollVoted {
guard !votedOptions.isEmpty else {
return .none
}
return votedOptions.contains(option) ? .on : .off
} else if poll.expired {
return .none
} else {
return .off
}
}()
let voteState: PollItem.Attribute.VoteState = {
guard isPollVoted else { return .hidden }
let percentage: Double = {
guard poll.votesCount.intValue > 0 else { return 0.0 }
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
}()
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option)
return .reveal(voted: voted, percentage: percentage)
}()
return PollItem.Attribute(selectState: selectState, voteState: voteState)
}()
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
return option
}
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
}
} }
extension StatusSection { extension StatusSection {

View File

@ -30,6 +30,10 @@ internal enum Asset {
} }
internal enum Colors { internal enum Colors {
internal enum Background { internal enum Background {
internal enum Poll {
internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled")
internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight")
}
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")

View File

@ -69,6 +69,8 @@ internal enum L10n {
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))
} }
internal enum Poll { internal enum Poll {
/// Closed
internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
/// %@ left /// %@ left
internal static func timeLeft(_ p1: Any) -> String { internal static func timeLeft(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1))

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.784",
"green" : "0.682",
"red" : "0.608"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.851",
"green" : "0.565",
"red" : "0.169"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -17,6 +17,7 @@
"Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left";
"Common.Controls.Status.Poll.Vote" = "Vote"; "Common.Controls.Status.Poll.Vote" = "Vote";
"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; "Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes";

View File

@ -0,0 +1,137 @@
//
// VoteProgressStripView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import UIKit
import Combine
final class VoteProgressStripView: UIView {
var disposeBag = Set<AnyCancellable>()
private lazy var stripLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.lineCap = .round
shapeLayer.fillColor = tintColor.cgColor
shapeLayer.strokeColor = UIColor.clear.cgColor
return shapeLayer
}()
let progressMaskLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.red.cgColor
return shapeLayer
}()
let progress = CurrentValueSubject<CGFloat, Never>(0.0)
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension VoteProgressStripView {
private func _init() {
updateLayerPath()
layer.addSublayer(stripLayer)
progress
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
guard let self = self else { return }
self.updateLayerPath()
}
.store(in: &disposeBag)
}
override func layoutSubviews() {
super.layoutSubviews()
updateLayerPath()
}
}
extension VoteProgressStripView {
private func updateLayerPath() {
guard bounds != .zero else { return }
stripLayer.frame = bounds
stripLayer.fillColor = tintColor.cgColor
stripLayer.path = {
let path = UIBezierPath(roundedRect: bounds, cornerRadius: 0)
return path.cgPath
}()
progressMaskLayer.path = {
var rect = bounds
let newWidth = progress.value * rect.width
let widthChanged = rect.width - newWidth
rect.size.width = newWidth
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
rect.origin.x += widthChanged
default:
break
}
let path = UIBezierPath(rect: rect)
return path.cgPath
}()
stripLayer.mask = progressMaskLayer
}
}
#if DEBUG
import SwiftUI
struct VoteProgressStripView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview() {
VoteProgressStripView()
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
UIViewPreview() {
let bar = VoteProgressStripView()
bar.tintColor = .white
bar.progress.value = 0.5
return bar
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
UIViewPreview() {
let bar = VoteProgressStripView()
bar.tintColor = .white
bar.progress.value = 1.0
return bar
}
.frame(width: 100, height: 44)
.padding()
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}
}
#endif

View File

@ -16,9 +16,15 @@ final class PollOptionTableViewCell: UITableViewCell {
static let checkmarkImageSize = CGSize(width: 26, height: 26) static let checkmarkImageSize = CGSize(width: 26, height: 26)
private var viewStateDisposeBag = Set<AnyCancellable>() private var viewStateDisposeBag = Set<AnyCancellable>()
private(set) var pollState: PollState = .off var selectState: PollItem.Attribute.SelectState = .off
var voteState: PollItem.Attribute.VoteState?
let roundedBackgroundView = UIView() let roundedBackgroundView = UIView()
let voteProgressStripView: VoteProgressStripView = {
let view = VoteProgressStripView()
view.tintColor = Asset.Colors.Background.Poll.highlight.color
return view
}()
let checkmarkBackgroundView: UIView = { let checkmarkBackgroundView: UIView = {
let view = UIView() let view = UIView()
@ -43,6 +49,8 @@ final class PollOptionTableViewCell: UITableViewCell {
return label return label
}() }()
let optionLabelMiddlePaddingView = UIView()
let optionPercentageLabel: UILabel = { let optionPercentageLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.font = .systemFont(ofSize: 13, weight: .regular) label.font = .systemFont(ofSize: 13, weight: .regular)
@ -64,16 +72,26 @@ final class PollOptionTableViewCell: UITableViewCell {
override func setSelected(_ selected: Bool, animated: Bool) { override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated) super.setSelected(selected, animated: animated)
guard let voteState = voteState else { return }
switch voteState {
case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color
self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
case .reveal:
break
}
} }
override func setHighlighted(_ highlighted: Bool, animated: Bool) { override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated) super.setHighlighted(highlighted, animated: animated)
switch pollState { guard let voteState = voteState else { return }
case .off, .none: switch voteState {
case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color let color = Asset.Colors.Background.systemGroupedBackground.color
self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color
case .on: case .reveal:
break break
} }
} }
@ -97,6 +115,15 @@ extension PollOptionTableViewCell {
roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.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 checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(checkmarkBackgroundView) roundedBackgroundView.addSubview(checkmarkBackgroundView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -123,23 +150,32 @@ extension PollOptionTableViewCell {
optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), 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 optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false
roundedBackgroundView.addSubview(optionPercentageLabel) roundedBackgroundView.addSubview(optionPercentageLabel)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor, constant: 8), optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor),
roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18),
optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor),
]) ])
optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal)
optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
configure(state: .none)
} }
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
updateCornerRadius() updateCornerRadius()
updateTextAppearance()
} }
private func updateCornerRadius() { private func updateCornerRadius() {
@ -152,41 +188,35 @@ extension PollOptionTableViewCell {
checkmarkBackgroundView.layer.cornerCurve = .circular checkmarkBackgroundView.layer.cornerCurve = .circular
} }
} func updateTextAppearance() {
guard let voteState = voteState else {
extension PollOptionTableViewCell {
enum PollState {
case none
case off
case on
}
func configure(state: PollState) {
switch state {
case .none:
checkmarkBackgroundView.backgroundColor = .clear
checkmarkImageView.isHidden = true
optionPercentageLabel.isHidden = true
optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow() optionLabel.layer.removeShadow()
case .off: return
checkmarkBackgroundView.backgroundColor = .systemBackground
checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
checkmarkBackgroundView.layer.borderWidth = 1
checkmarkImageView.isHidden = true
optionPercentageLabel.isHidden = true
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow()
case .on:
checkmarkBackgroundView.backgroundColor = .systemBackground
checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
checkmarkBackgroundView.layer.borderWidth = 0
checkmarkImageView.isHidden = false
optionPercentageLabel.isHidden = false
optionLabel.textColor = .white
optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)
} }
switch voteState {
case .hidden:
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.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)
} else {
optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.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)
} else {
optionPercentageLabel.textColor = Asset.Colors.Label.primary.color
optionPercentageLabel.layer.removeShadow()
}
}
} }
} }
@ -205,13 +235,13 @@ struct PollTableViewCell_Previews: PreviewProvider {
.previewLayout(.fixed(width: 375, height: 44 + 10)) .previewLayout(.fixed(width: 375, height: 44 + 10))
UIViewPreview() { UIViewPreview() {
let cell = PollOptionTableViewCell() let cell = PollOptionTableViewCell()
cell.configure(state: .off) PollSection.configure(cell: cell, selectState: .off)
return cell return cell
} }
.previewLayout(.fixed(width: 375, height: 44 + 10)) .previewLayout(.fixed(width: 375, height: 44 + 10))
UIViewPreview() { UIViewPreview() {
let cell = PollOptionTableViewCell() let cell = PollOptionTableViewCell()
cell.configure(state: .on) PollSection.configure(cell: cell, selectState: .on)
return cell return cell
} }
.previewLayout(.fixed(width: 375, height: 44 + 10)) .previewLayout(.fixed(width: 375, height: 44 + 10))

View File

@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell {
weak var delegate: StatusTableViewCellDelegate? weak var delegate: StatusTableViewCellDelegate?
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var pollCountdownSubscription: AnyCancellable?
var observations = Set<NSKeyValueObservation>() var observations = Set<NSKeyValueObservation>()
let statusView = StatusView() let statusView = StatusView()

View File

@ -56,7 +56,8 @@ extension APIService.CoreData {
let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil
return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy) return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy)
} }
let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), options: options) let votedBy: MastodonUser? = (poll.voted ?? false) ? requestMastodonUser : nil
let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options)
return object return object
} }
let metions = entity.mentions?.compactMap { mention -> Mention in let metions = entity.mentions?.compactMap { mention -> Mention in
@ -116,21 +117,8 @@ extension APIService.CoreData {
guard networkDate > toot.updatedAt else { return } guard networkDate > toot.updatedAt else { return }
// merge poll // merge poll
if let poll = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count { if let poll = toot.poll, let entity = entity.poll {
oldPoll.update(expiresAt: poll.expiresAt) merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
oldPoll.update(expired: poll.expired)
oldPoll.update(votesCount: poll.votesCount)
oldPoll.update(votersCount: poll.votersCount)
let oldOptions = oldPoll.options.sorted(by: { $0.index.intValue < $1.index.intValue })
for (i, (option, oldOption)) in zip(poll.options, oldOptions).enumerated() {
let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil
oldOption.update(votesCount: option.votesCount)
votedBy.flatMap { oldOption.update(votedBy: $0) }
oldOption.didUpdate(at: networkDate)
}
oldPoll.didUpdate(at: networkDate)
} }
// merge metrics // merge metrics
@ -188,12 +176,15 @@ extension APIService.CoreData {
poll.update(expired: entity.expired) poll.update(expired: entity.expired)
poll.update(votesCount: entity.votesCount) poll.update(votesCount: entity.votesCount)
poll.update(votersCount: entity.votersCount) poll.update(votersCount: entity.votersCount)
requestMastodonUser.flatMap {
poll.update(voted: entity.voted ?? false, by: $0)
}
let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue })
for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() {
let votedBy: MastodonUser? = (entity.ownVotes ?? []).contains(i) ? requestMastodonUser : nil let voted: Bool = (entity.ownVotes ?? []).contains(i)
option.update(votesCount: optionEntity.votesCount) option.update(votesCount: optionEntity.votesCount)
votedBy.flatMap { option.update(votedBy: $0) } requestMastodonUser.flatMap { option.update(voted: voted, by: $0) }
option.didUpdate(at: networkDate) option.didUpdate(at: networkDate)
} }