From 3b8772c5da1eaa494146d1204e99e1d19f29f9f1 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 28 Dec 2022 10:08:41 +0100 Subject: [PATCH] Status: View & votes on polls --- IceCubesApp/App/AppRouteur.swift | 2 +- Packages/Models/Sources/Models/Poll.swift | 23 ++++ Packages/Models/Sources/Models/Status.swift | 6 +- .../Sources/Network/Endpoint/Polls.swift | 29 ++++ .../Sources/Status/Poll/StatusPollView.swift | 126 ++++++++++++++++++ .../Status/Poll/StatusPollViewModel.swift | 41 ++++++ .../Sources/Status/Row/StatusRowView.swift | 4 + 7 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 Packages/Models/Sources/Models/Poll.swift create mode 100644 Packages/Network/Sources/Network/Endpoint/Polls.swift create mode 100644 Packages/Status/Sources/Status/Poll/StatusPollView.swift create mode 100644 Packages/Status/Sources/Status/Poll/StatusPollViewModel.swift diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 11f3c9fe..1b6e31db 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -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): diff --git a/Packages/Models/Sources/Models/Poll.swift b/Packages/Models/Sources/Models/Poll.swift new file mode 100644 index 00000000..d40e2373 --- /dev/null +++ b/Packages/Models/Sources/Models/Poll.swift @@ -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] +} diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index 58296c8b..7ff32711 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -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? } diff --git a/Packages/Network/Sources/Network/Endpoint/Polls.swift b/Packages/Network/Sources/Network/Endpoint/Polls.swift new file mode 100644 index 00000000..6add0ea5 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Polls.swift @@ -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 + } + } +} diff --git a/Packages/Status/Sources/Status/Poll/StatusPollView.swift b/Packages/Status/Sources/Status/Poll/StatusPollView.swift new file mode 100644 index 00000000..a0063000 --- /dev/null +++ b/Packages/Status/Sources/Status/Poll/StatusPollView.swift @@ -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) + } + } +} diff --git a/Packages/Status/Sources/Status/Poll/StatusPollViewModel.swift b/Packages/Status/Sources/Status/Poll/StatusPollViewModel.swift new file mode 100644 index 00000000..a6185632 --- /dev/null +++ b/Packages/Status/Sources/Status/Poll/StatusPollViewModel.swift @@ -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) + } + } +} diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index d67c363d..e70c57a7 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -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")