feat: make poll cell label appearance update according to the underneath background
This commit is contained in:
parent
30c035e09a
commit
028f3a9404
|
@ -84,6 +84,7 @@
|
|||
<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="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 name="Mention" representedClassName=".Mention" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
|
@ -105,6 +106,7 @@
|
|||
<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="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 name="PollOption" representedClassName=".PollOption" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
@ -166,9 +168,9 @@
|
|||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<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="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="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="539"/>
|
||||
|
|
|
@ -38,6 +38,7 @@ final public class MastodonUser: NSManagedObject {
|
|||
@NSManaged public private(set) var muted: Set<Toot>?
|
||||
@NSManaged public private(set) var bookmarked: Set<Toot>?
|
||||
@NSManaged public private(set) var votePollOptions: Set<PollOption>?
|
||||
@NSManaged public private(set) var votePolls: Set<Poll>?
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,9 @@ public final class Poll: NSManagedObject {
|
|||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var options: Set<PollOption>
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
|
||||
}
|
||||
|
||||
extension Poll {
|
||||
|
@ -39,6 +42,7 @@ extension Poll {
|
|||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
votedBy: MastodonUser?,
|
||||
options: [PollOption]
|
||||
) -> Poll {
|
||||
let poll: Poll = context.insertObject()
|
||||
|
@ -50,7 +54,12 @@ extension Poll {
|
|||
poll.votesCount = property.votesCount
|
||||
poll.votersCount = property.votersCount
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
|
|
@ -56,9 +56,15 @@ extension PollOption {
|
|||
}
|
||||
}
|
||||
|
||||
public func update(votedBy: MastodonUser) {
|
||||
if !(self.votedBy ?? Set()).contains(votedBy) {
|
||||
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy)
|
||||
public func update(voted: Bool, by: MastodonUser) {
|
||||
if voted {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -69,7 +69,8 @@
|
|||
"single": "%d voter",
|
||||
"multiple": "%d voters",
|
||||
},
|
||||
"time_left": "%s left"
|
||||
"time_left": "%s left",
|
||||
"closed": "Closed"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
|
|
@ -129,6 +129,7 @@
|
|||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.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 */; };
|
||||
DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */; };
|
||||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; };
|
||||
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -525,6 +527,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D152A8B25C295CC009AA50C /* StatusView.swift */,
|
||||
DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1520,6 +1523,7 @@
|
|||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||
DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */,
|
||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
||||
|
|
|
@ -15,20 +15,34 @@ enum PollItem {
|
|||
|
||||
extension PollItem {
|
||||
class Attribute: Hashable {
|
||||
// var pollVotable: Bool
|
||||
var isOptionVoted: Bool
|
||||
|
||||
init(isOptionVoted: Bool) {
|
||||
// self.pollVotable = pollVotable
|
||||
self.isOptionVoted = isOptionVoted
|
||||
enum SelectState: Equatable, Hashable {
|
||||
case none
|
||||
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 {
|
||||
return lhs.isOptionVoted == rhs.isOptionVoted
|
||||
return lhs.selectState == rhs.selectState &&
|
||||
lhs.voteState == rhs.voteState
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(isOptionVoted)
|
||||
hasher.combine(selectState)
|
||||
hasher.combine(voteState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,49 @@ extension PollSection {
|
|||
itemAttribute: PollItem.Attribute
|
||||
) {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -66,6 +66,9 @@ extension StatusSection {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusSection {
|
||||
|
||||
static func configure(
|
||||
cell: StatusTableViewCell,
|
||||
|
@ -155,52 +158,20 @@ extension StatusSection {
|
|||
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
|
||||
// set poll
|
||||
if let poll = (toot.reblog ?? toot).poll {
|
||||
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)
|
||||
}
|
||||
let poll = (toot.reblog ?? toot).poll
|
||||
configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID)
|
||||
if let poll = poll {
|
||||
ManagedObjectObserver.observe(object: poll)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { change in
|
||||
guard case let .update(object) = change.changeType,
|
||||
let newPoll = object as? Poll else { return }
|
||||
StatusSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID)
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
|
||||
// toolbar
|
||||
let replyCountTitle: String = {
|
||||
let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0
|
||||
|
@ -244,6 +215,104 @@ extension StatusSection {
|
|||
}
|
||||
.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 {
|
||||
|
|
|
@ -30,6 +30,10 @@ internal enum Asset {
|
|||
}
|
||||
internal enum Colors {
|
||||
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 secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
|
||||
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
|
||||
|
|
|
@ -69,6 +69,8 @@ internal enum L10n {
|
|||
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))
|
||||
}
|
||||
internal enum Poll {
|
||||
/// Closed
|
||||
internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed")
|
||||
/// %@ left
|
||||
internal static func timeLeft(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1))
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
"Common.Controls.Actions.SignUp" = "Sign Up";
|
||||
"Common.Controls.Actions.TakePhoto" = "Take photo";
|
||||
"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.Vote" = "Vote";
|
||||
"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes";
|
||||
|
|
|
@ -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
|
|
@ -16,9 +16,15 @@ final class PollOptionTableViewCell: UITableViewCell {
|
|||
static let checkmarkImageSize = CGSize(width: 26, height: 26)
|
||||
|
||||
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 voteProgressStripView: VoteProgressStripView = {
|
||||
let view = VoteProgressStripView()
|
||||
view.tintColor = Asset.Colors.Background.Poll.highlight.color
|
||||
return view
|
||||
}()
|
||||
|
||||
let checkmarkBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
|
@ -43,6 +49,8 @@ final class PollOptionTableViewCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
let optionLabelMiddlePaddingView = UIView()
|
||||
|
||||
let optionPercentageLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
|
@ -64,16 +72,26 @@ final class PollOptionTableViewCell: UITableViewCell {
|
|||
|
||||
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||
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) {
|
||||
super.setHighlighted(highlighted, animated: animated)
|
||||
|
||||
switch pollState {
|
||||
case .off, .none:
|
||||
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 .on:
|
||||
case .reveal:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -97,6 +115,15 @@ extension PollOptionTableViewCell {
|
|||
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
|
||||
roundedBackgroundView.addSubview(checkmarkBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -123,23 +150,32 @@ extension PollOptionTableViewCell {
|
|||
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: optionLabel.trailingAnchor, constant: 8),
|
||||
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)
|
||||
|
||||
configure(state: .none)
|
||||
optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
updateCornerRadius()
|
||||
updateTextAppearance()
|
||||
}
|
||||
|
||||
private func updateCornerRadius() {
|
||||
|
@ -152,41 +188,35 @@ extension PollOptionTableViewCell {
|
|||
checkmarkBackgroundView.layer.cornerCurve = .circular
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
func updateTextAppearance() {
|
||||
guard let voteState = voteState else {
|
||||
optionLabel.textColor = Asset.Colors.Label.primary.color
|
||||
optionLabel.layer.removeShadow()
|
||||
case .off:
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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))
|
||||
UIViewPreview() {
|
||||
let cell = PollOptionTableViewCell()
|
||||
cell.configure(state: .off)
|
||||
PollSection.configure(cell: cell, selectState: .off)
|
||||
return cell
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44 + 10))
|
||||
UIViewPreview() {
|
||||
let cell = PollOptionTableViewCell()
|
||||
cell.configure(state: .on)
|
||||
PollSection.configure(cell: cell, selectState: .on)
|
||||
return cell
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44 + 10))
|
||||
|
|
|
@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell {
|
|||
weak var delegate: StatusTableViewCellDelegate?
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var pollCountdownSubscription: AnyCancellable?
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
let statusView = StatusView()
|
||||
|
|
|
@ -56,7 +56,8 @@ extension APIService.CoreData {
|
|||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
let metions = entity.mentions?.compactMap { mention -> Mention in
|
||||
|
@ -116,21 +117,8 @@ extension APIService.CoreData {
|
|||
guard networkDate > toot.updatedAt else { return }
|
||||
|
||||
// merge poll
|
||||
if let poll = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count {
|
||||
oldPoll.update(expiresAt: poll.expiresAt)
|
||||
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)
|
||||
if let poll = toot.poll, let entity = entity.poll {
|
||||
merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate)
|
||||
}
|
||||
|
||||
// merge metrics
|
||||
|
@ -188,12 +176,15 @@ extension APIService.CoreData {
|
|||
poll.update(expired: entity.expired)
|
||||
poll.update(votesCount: entity.votesCount)
|
||||
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 })
|
||||
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)
|
||||
votedBy.flatMap { option.update(votedBy: $0) }
|
||||
requestMastodonUser.flatMap { option.update(voted: voted, by: $0) }
|
||||
option.didUpdate(at: networkDate)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue