feat: implement single vote poll

This commit is contained in:
CMK 2021-03-05 13:41:48 +08:00
parent 58c8eaabe8
commit 11cee6df35
10 changed files with 81 additions and 48 deletions

View File

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

View File

@ -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

View File

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

View File

@ -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<Date, Never>,
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<Date, Never>,
poll: Poll?,
requestUserID: String
requestUserID: String,
updateProgressAnimated: Bool,
timestampUpdatePublisher: AnyPublisher<Date, Never>
) {
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)
}()

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Poll>, 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)
}
}

View File

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

View File

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

View File

@ -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

View File

@ -16,8 +16,7 @@ final class PollOptionTableViewCell: UITableViewCell {
static let checkmarkImageSize = CGSize(width: 26, height: 26)
private var viewStateDisposeBag = Set<AnyCancellable>()
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)

View File

@ -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,