forked from zelo72/mastodon-ios
feat: implement single vote poll
This commit is contained in:
parent
58c8eaabe8
commit
11cee6df35
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}()
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue