diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 96ec6971d..3f8fe73f9 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -84,6 +84,7 @@
+
@@ -105,6 +106,7 @@
+
@@ -166,9 +168,9 @@
-
+
-
+
diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift
index 8ecf66282..dc88d48a2 100644
--- a/CoreDataStack/Entity/MastodonUser.swift
+++ b/CoreDataStack/Entity/MastodonUser.swift
@@ -38,6 +38,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var muted: Set?
@NSManaged public private(set) var bookmarked: Set?
@NSManaged public private(set) var votePollOptions: Set?
+ @NSManaged public private(set) var votePolls: Set?
}
diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift
index a7f6d431a..cc5e7bbcb 100644
--- a/CoreDataStack/Entity/Poll.swift
+++ b/CoreDataStack/Entity/Poll.swift
@@ -26,6 +26,9 @@ public final class Poll: NSManagedObject {
// one-to-many relationship
@NSManaged public private(set) var options: Set
+
+ // many-to-many relationship
+ @NSManaged public private(set) var votedBy: Set?
}
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
}
diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift
index 6c88fe609..14a076144 100644
--- a/CoreDataStack/Entity/PollOption.swift
+++ b/CoreDataStack/Entity/PollOption.swift
@@ -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)
+ }
}
}
diff --git a/Localization/app.json b/Localization/app.json
index d35443b74..d1b0e3c5c 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -69,7 +69,8 @@
"single": "%d voter",
"multiple": "%d voters",
},
- "time_left": "%s left"
+ "time_left": "%s left",
+ "closed": "Closed"
}
},
"timeline": {
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index ed78ec478..439696738 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -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 = ""; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; };
+ DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.swift; sourceTree = ""; };
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; };
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; };
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; };
@@ -525,6 +527,7 @@
isa = PBXGroup;
children = (
2D152A8B25C295CC009AA50C /* StatusView.swift */,
+ DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */,
);
path = Content;
sourceTree = "";
@@ -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 */,
diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift
index ca1bbc364..e4b0ff8df 100644
--- a/Mastodon/Diffiable/Item/PollItem.swift
+++ b/Mastodon/Diffiable/Item/PollItem.swift
@@ -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)
}
}
}
diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift
index de303d4a0..eff868a79 100644
--- a/Mastodon/Diffiable/Section/PollSection.swift
+++ b/Mastodon/Diffiable/Section/PollSection.swift
@@ -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()
+ }
+
+}
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift
index 2c88f7f76..38e6a2b68 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/StatusSection.swift
@@ -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()
- 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,
+ 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()
+ 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 {
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index 08507ed9d..d65991fe2 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -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")
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 190657e94..c352473d2 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -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))
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json
new file mode 100644
index 000000000..6e965652d
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json
new file mode 100644
index 000000000..78cde95fb
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json
new file mode 100644
index 000000000..2e1ce5f3a
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index a9480500f..6071cbee3 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -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";
diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift
new file mode 100644
index 000000000..30c9e22ca
--- /dev/null
+++ b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift
@@ -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()
+
+ 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(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
diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift
index 1da1246b1..32f59964c 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift
@@ -16,9 +16,15 @@ final class PollOptionTableViewCell: UITableViewCell {
static let checkmarkImageSize = CGSize(width: 26, height: 26)
private var viewStateDisposeBag = Set()
- 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))
diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
index 1c45bfe2d..2a62d1a40 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
@@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell {
weak var delegate: StatusTableViewCellDelegate?
var disposeBag = Set()
+ var pollCountdownSubscription: AnyCancellable?
var observations = Set()
let statusView = StatusView()
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift
index 6868c668f..79fad947e 100644
--- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift
@@ -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)
}