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):
|
||||
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)))
|
||||
case let .following(id):
|
||||
AccountsListView(mode: .followers(accountId: id))
|
||||
AccountsListView(mode: .following(accountId: id))
|
||||
case let .followers(id):
|
||||
AccountsListView(mode: .followers(accountId: 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 inReplyToAccountId: String? { 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 inReplyToAccountId: String?
|
||||
public let visibility: Visibility
|
||||
public let poll: Poll?
|
||||
|
||||
public static func placeholder() -> Status {
|
||||
.init(id: UUID().uuidString,
|
||||
|
@ -85,7 +87,8 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
|||
url: nil,
|
||||
application: nil,
|
||||
inReplyToAccountId: nil,
|
||||
visibility: .pub)
|
||||
visibility: .pub,
|
||||
poll: nil)
|
||||
}
|
||||
|
||||
public static func placeholders() -> [Status] {
|
||||
|
@ -117,4 +120,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
|||
public var application: Application?
|
||||
public let inReplyToAccountId: String?
|
||||
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)
|
||||
}
|
||||
|
||||
if let poll = status.poll {
|
||||
StatusPollView(poll: poll)
|
||||
}
|
||||
|
||||
if !status.mediaAttachments.isEmpty {
|
||||
if viewModel.isEmbed {
|
||||
Image(systemName: "paperclip")
|
||||
|
|
Loading…
Reference in New Issue