From 11cee6df357371bb612816c662ee16f0daee9fa1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 13:41:48 +0800 Subject: [PATCH] feat: implement single vote poll --- Mastodon/Diffiable/Item/Item.swift | 8 ++--- Mastodon/Diffiable/Item/PollItem.swift | 2 +- Mastodon/Diffiable/Section/PollSection.swift | 25 +++++++------- .../Diffiable/Section/StatusSection.swift | 33 +++++++++++++------ ...Provider+StatusTableViewCellDelegate.swift | 21 ++++++++++-- .../HomeTimelineViewModel+Diffable.swift | 4 +-- .../PublicTimelineViewModel+Diffable.swift | 4 +-- .../View/Control/StripProgressView.swift | 19 ++++++++--- .../PollOptionTableViewCell.swift | 11 +++---- .../Service/APIService/APIService+Poll.swift | 2 +- 10 files changed, 81 insertions(+), 48 deletions(-) diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..645dcd7a8 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -13,10 +13,10 @@ import MastodonSDK /// Note: update Equatable when change case enum Item { // timeline - case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) // normal list - case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case toot(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) @@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute { } extension Item { - class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { + class StatusAttribute: Hashable, StatusContentWarningAttribute { var isStatusTextSensitive: Bool var isStatusSensitive: Bool @@ -42,7 +42,7 @@ extension Item { self.isStatusSensitive = isStatusSensitive } - static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { + static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool { return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && lhs.isStatusSensitive == rhs.isStatusSensitive } diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index e4b0ff8df..006400f9e 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -24,7 +24,7 @@ extension PollItem { enum VoteState: Equatable, Hashable { case hidden - case reveal(voted: Bool, percentage: Double) + case reveal(voted: Bool, percentage: Double, animated: Bool) } var selectState: SelectState diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 54753a7a0..d7a7b43f1 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -24,7 +24,7 @@ extension PollSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell managedObjectContext.performAndWait { let option = managedObjectContext.object(with: objectID) as! PollOption - PollSection.configure(cell: cell, pollOption: option, itemAttribute: attribute) + PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute) } return cell } @@ -35,12 +35,15 @@ extension PollSection { extension PollSection { static func configure( cell: PollOptionTableViewCell, - pollOption: PollOption, - itemAttribute: PollItem.Attribute + pollOption option: PollOption, + pollItemAttribute attribute: PollItem.Attribute ) { - cell.optionLabel.text = pollOption.title - configure(cell: cell, selectState: itemAttribute.selectState) - configure(cell: cell, voteState: itemAttribute.voteState) + cell.optionLabel.text = option.title + configure(cell: cell, selectState: attribute.selectState) + configure(cell: cell, voteState: attribute.voteState) + cell.attribute = attribute + cell.layoutIfNeeded() + cell.updateTextAppearance() } } @@ -64,24 +67,18 @@ extension PollSection { 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): + case .reveal(let voted, let percentage, let animated): 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.setProgress(CGFloat(percentage), animated: true) + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } - cell.voteState = state - - cell.layoutIfNeeded() - cell.updateTextAppearance() } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index d442a23e3..3c15996d6 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -34,7 +34,7 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute) } cell.delegate = statusTableViewCellDelegate return cell @@ -45,7 +45,7 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute) } cell.delegate = statusTableViewCellDelegate return cell @@ -76,7 +76,7 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher, toot: Toot, requestUserID: String, - statusContentWarningAttribute: StatusContentWarningAttribute? + statusItemAttribute: Item.StatusAttribute ) { // set header cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil @@ -99,7 +99,7 @@ extension StatusSection { // set status text content warning let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.contentWarningTitle.text = { @@ -153,13 +153,19 @@ extension StatusSection { } } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive + let isStatusSensitive = statusItemAttribute.isStatusSensitive cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll let poll = (toot.reblog ?? toot).poll - configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID) + StatusSection.configure( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) if let poll = poll { ManagedObjectObserver.observe(object: poll) .sink { _ in @@ -167,7 +173,13 @@ extension StatusSection { } 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) + StatusSection.configure( + cell: cell, + poll: newPoll, + requestUserID: requestUserID, + updateProgressAnimated: true, + timestampUpdatePublisher: timestampUpdatePublisher + ) } .store(in: &cell.disposeBag) } @@ -218,9 +230,10 @@ extension StatusSection { static func configure( cell: StatusTableViewCell, - timestampUpdatePublisher: AnyPublisher, poll: Poll?, - requestUserID: String + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, let managedObjectContext = poll.managedObjectContext else { @@ -302,7 +315,7 @@ extension StatusSection { 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 .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) }() diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 6a4d2df62..d62fb6cca 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -75,6 +75,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } @@ -82,24 +83,38 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard case let .opion(objectID, attribute) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } + let domain = option.poll.toot.domain + let pollObjectID = option.poll.objectID if option.poll.multiple { var choices: [Int] = [] } else { + let choices = [option.index.intValue] context.apiService.vote( pollObjectID: option.poll.objectID, mastodonUserObjectID: activeMastodonAuthentication.user.objectID, choices: [option.index.intValue] ) + .handleEvents(receiveOutput: { _ in + // TODO: add haptic + }) + .flatMap { pollID -> AnyPublisher, Error> in + return self.context.apiService.vote( + domain: domain, + pollID: pollID, + pollObjectID: pollObjectID, + choices: choices, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } .receive(on: DispatchQueue.main) .sink { completion in - } receiveValue: { pollID in - + } receiveValue: { response in + print(response.value) } .store(in: &context.disposeBag) - } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index fffa4b7f7..4a34b922a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -73,7 +73,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { // that's will be the most fastest fetch because of upstream just update and no modify needs consider - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } @@ -88,7 +88,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index fa17319b4..d69da8f65 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -50,7 +50,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { return indexes.firstIndex(of: toot.id).map { index in (index, toot) } } .sorted { $0.0 < $1.0 } - var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { guard case let .toot(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute @@ -63,7 +63,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) + let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) if tootIDsWhichHasGap.contains(toot.id) { items.append(Item.publicMiddleLoader(tootID: toot.id)) diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift index ae3f86735..e6550ba3d 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -11,12 +11,15 @@ import Combine private final class StripProgressLayer: CALayer { + static let progressAnimationKey = "progressAnimationKey" + static let progressKey = "progress" + var tintColor: UIColor = .black @NSManaged var progress: CGFloat override class func needsDisplay(forKey key: String) -> Bool { switch key { - case "progress": + case StripProgressLayer.progressKey: return true default: return super.needsDisplay(forKey: key) @@ -24,7 +27,13 @@ private final class StripProgressLayer: CALayer { } override func display() { - let progress = presentation()?.progress ?? self.progress + let progress: CGFloat = { + guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else { + return self.progress + } + + return presentation()?.progress ?? self.progress + }() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) @@ -72,15 +81,15 @@ final class StripProgressView: UIView { } func setProgress(_ progress: CGFloat, animated: Bool) { - stripProgressLayer.removeAnimation(forKey: "progressAnimationKey") + stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey) if animated { - let animation = CABasicAnimation(keyPath: "progress") + let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey) animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress animation.toValue = progress animation.duration = 0.33 animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.isRemovedOnCompletion = true - stripProgressLayer.add(animation, forKey: "progressAnimationKey") + stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey) stripProgressLayer.progress = progress } else { stripProgressLayer.progress = progress diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 455e5fb9c..7aa7ef41d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -16,8 +16,7 @@ final class PollOptionTableViewCell: UITableViewCell { static let checkmarkImageSize = CGSize(width: 26, height: 26) private var viewStateDisposeBag = Set() - var selectState: PollItem.Attribute.SelectState = .off - var voteState: PollItem.Attribute.VoteState? + var attribute: PollItem.Attribute? let roundedBackgroundView = UIView() let voteProgressStripView: StripProgressView = { @@ -73,7 +72,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -86,7 +85,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -189,7 +188,7 @@ extension PollOptionTableViewCell { } func updateTextAppearance() { - guard let voteState = voteState else { + guard let voteState = attribute?.voteState else { optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() return @@ -199,7 +198,7 @@ extension PollOptionTableViewCell { case .hidden: optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() - case .reveal(_, let percentage): + 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) diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 7ee3bb029..350da97c8 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -135,7 +135,7 @@ extension APIService { .eraseToAnyPublisher() } - // send vote request to remote + /// send vote request to remote func vote( domain: String, pollID: Mastodon.Entity.Poll.ID,