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) }