Status: View & votes on polls
This commit is contained in:
parent
5b9f91abd1
commit
3b8772c5da
|
@ -18,7 +18,7 @@ extension View {
|
||||||
case let .hashTag(tag, accountId):
|
case let .hashTag(tag, accountId):
|
||||||
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)))
|
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)))
|
||||||
case let .following(id):
|
case let .following(id):
|
||||||
AccountsListView(mode: .followers(accountId: id))
|
AccountsListView(mode: .following(accountId: id))
|
||||||
case let .followers(id):
|
case let .followers(id):
|
||||||
AccountsListView(mode: .followers(accountId: id))
|
AccountsListView(mode: .followers(accountId: id))
|
||||||
case let .favouritedBy(id):
|
case let .favouritedBy(id):
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Poll: Codable {
|
||||||
|
public struct Option: Identifiable, Codable {
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case title, votesCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id = UUID().uuidString
|
||||||
|
public let title: String
|
||||||
|
public let votesCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public let id: String
|
||||||
|
public let expiresAt: ServerDate
|
||||||
|
public let expired: Bool
|
||||||
|
public let multiple: Bool
|
||||||
|
public let votesCount: Int
|
||||||
|
public let votersCount: Int
|
||||||
|
public let voted: Bool
|
||||||
|
public let ownVotes: [Int]
|
||||||
|
public let options: [Option]
|
||||||
|
}
|
|
@ -36,6 +36,7 @@ public protocol AnyStatus {
|
||||||
var application: Application? { get }
|
var application: Application? { get }
|
||||||
var inReplyToAccountId: String? { get }
|
var inReplyToAccountId: String? { get }
|
||||||
var visibility: Visibility { get }
|
var visibility: Visibility { get }
|
||||||
|
var poll: Poll? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -64,6 +65,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
||||||
public let application: Application?
|
public let application: Application?
|
||||||
public let inReplyToAccountId: String?
|
public let inReplyToAccountId: String?
|
||||||
public let visibility: Visibility
|
public let visibility: Visibility
|
||||||
|
public let poll: Poll?
|
||||||
|
|
||||||
public static func placeholder() -> Status {
|
public static func placeholder() -> Status {
|
||||||
.init(id: UUID().uuidString,
|
.init(id: UUID().uuidString,
|
||||||
|
@ -85,7 +87,8 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
||||||
url: nil,
|
url: nil,
|
||||||
application: nil,
|
application: nil,
|
||||||
inReplyToAccountId: nil,
|
inReplyToAccountId: nil,
|
||||||
visibility: .pub)
|
visibility: .pub,
|
||||||
|
poll: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func placeholders() -> [Status] {
|
public static func placeholders() -> [Status] {
|
||||||
|
@ -117,4 +120,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
||||||
public var application: Application?
|
public var application: Application?
|
||||||
public let inReplyToAccountId: String?
|
public let inReplyToAccountId: String?
|
||||||
public let visibility: Visibility
|
public let visibility: Visibility
|
||||||
|
public let poll: Poll?
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Polls: Endpoint {
|
||||||
|
case poll(id: String)
|
||||||
|
case vote(id: String, votes: [Int])
|
||||||
|
|
||||||
|
public func path() -> String {
|
||||||
|
switch self {
|
||||||
|
case .poll(let id):
|
||||||
|
return "polls/\(id)/"
|
||||||
|
case .vote(let id, _):
|
||||||
|
return "polls/\(id)/votes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func queryItems() -> [URLQueryItem]? {
|
||||||
|
switch self {
|
||||||
|
case let .vote(_, votes):
|
||||||
|
var params: [URLQueryItem] = []
|
||||||
|
for vote in votes {
|
||||||
|
params.append(.init(name: "choices[]", value: "\(vote)"))
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,126 @@
|
||||||
|
import Models
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
import Env
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
public struct StatusPollView: View {
|
||||||
|
enum Constants {
|
||||||
|
static let barHeight: CGFloat = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
@EnvironmentObject private var client: Client
|
||||||
|
@EnvironmentObject private var currentInstance: CurrentInstance
|
||||||
|
@StateObject private var viewModel: StatusPollViewModel
|
||||||
|
|
||||||
|
public init(poll: Poll) {
|
||||||
|
_viewModel = StateObject(wrappedValue: .init(poll: poll))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func widthForOption(option: Poll.Option, proxy: GeometryProxy) -> CGFloat {
|
||||||
|
let totalWidth = proxy.frame(in: .local).width
|
||||||
|
let ratio = CGFloat(option.votesCount) / CGFloat(viewModel.poll.votesCount)
|
||||||
|
return totalWidth * ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentForOption(option: Poll.Option) -> Int {
|
||||||
|
let ratio = (Float(option.votesCount) / Float(viewModel.poll.votesCount)) * 100
|
||||||
|
return Int(ceil(ratio))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelected(option: Poll.Option) -> Bool {
|
||||||
|
for vote in viewModel.votes {
|
||||||
|
return viewModel.poll.options.firstIndex(where: { $0.id == option.id }) == vote
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ForEach(viewModel.poll.options) { option in
|
||||||
|
HStack {
|
||||||
|
makeBarView(for: option)
|
||||||
|
if !viewModel.votes.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
Text("\(percentForOption(option: option)) %")
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(width: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footerView
|
||||||
|
|
||||||
|
}.onAppear {
|
||||||
|
viewModel.instance = currentInstance.instance
|
||||||
|
viewModel.client = client
|
||||||
|
Task {
|
||||||
|
await viewModel.fetchPoll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("\(viewModel.poll.votesCount) votes")
|
||||||
|
Text(" ⸱ ")
|
||||||
|
if viewModel.poll.expired {
|
||||||
|
Text("Closed")
|
||||||
|
} else {
|
||||||
|
Text("Close in ")
|
||||||
|
Text(viewModel.poll.expiresAt.asDate, style: .timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeBarView(for option: Poll.Option) -> some View {
|
||||||
|
let isSelected = isSelected(option: option)
|
||||||
|
Button {
|
||||||
|
if !viewModel.poll.expired,
|
||||||
|
viewModel.votes.isEmpty,
|
||||||
|
let index = viewModel.poll.options.firstIndex(where: { $0.id == option.id }) {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.votes.append(index)
|
||||||
|
Task {
|
||||||
|
await viewModel.postVotes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Rectangle()
|
||||||
|
.background {
|
||||||
|
if viewModel.showResults {
|
||||||
|
HStack {
|
||||||
|
let width = widthForOption(option: option, proxy: proxy)
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(Color.brand)
|
||||||
|
.frame(height: Constants.barHeight)
|
||||||
|
.frame(width: width)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.brand.opacity(0.40))
|
||||||
|
.frame(height: Constants.barHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.mint)
|
||||||
|
}
|
||||||
|
Text(option.title)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: Constants.barHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Network
|
||||||
|
import Models
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public class StatusPollViewModel: ObservableObject {
|
||||||
|
public var client: Client?
|
||||||
|
public var instance: Instance?
|
||||||
|
|
||||||
|
@Published var poll: Poll
|
||||||
|
@Published var votes: [Int] = []
|
||||||
|
|
||||||
|
var showResults: Bool {
|
||||||
|
!votes.isEmpty || poll.expired
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(poll: Poll) {
|
||||||
|
self.poll = poll
|
||||||
|
self.votes = poll.ownVotes
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchPoll() async {
|
||||||
|
guard let client else { return }
|
||||||
|
do {
|
||||||
|
poll = try await client.get(endpoint: Polls.poll(id: poll.id))
|
||||||
|
votes = poll.ownVotes
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func postVotes() async {
|
||||||
|
guard let client, !poll.expired else { return }
|
||||||
|
do {
|
||||||
|
poll = try await client.post(endpoint: Polls.vote(id: poll.id, votes: votes))
|
||||||
|
withAnimation {
|
||||||
|
votes = poll.ownVotes
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -112,6 +112,10 @@ public struct StatusRowView: View {
|
||||||
StatusEmbededView(status: embed)
|
StatusEmbededView(status: embed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let poll = status.poll {
|
||||||
|
StatusPollView(poll: poll)
|
||||||
|
}
|
||||||
|
|
||||||
if !status.mediaAttachments.isEmpty {
|
if !status.mediaAttachments.isEmpty {
|
||||||
if viewModel.isEmbed {
|
if viewModel.isEmbed {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
|
|
Loading…
Reference in New Issue