Status: View & votes on polls

This commit is contained in:
Thomas Ricouard 2022-12-28 10:08:41 +01:00
parent 5b9f91abd1
commit 3b8772c5da
7 changed files with 229 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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