Viewing polls

This commit is contained in:
Lumaa 2024-03-09 15:51:37 +01:00
parent ad4b83d82b
commit 990b377ccf
7 changed files with 344 additions and 4 deletions

View File

@ -23,6 +23,7 @@
B95ED2332B8707D60055F5BD /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = B95ED2322B8707D60055F5BD /* RevenueCat */; };
B95ED2352B8707D60055F5BD /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = B95ED2342B8707D60055F5BD /* RevenueCatUI */; };
B95ED2372B87C9550055F5BD /* StoreKitTestCertificate.cer in Resources */ = {isa = PBXBuildFile; fileRef = B95ED2362B87C9550055F5BD /* StoreKitTestCertificate.cer */; };
B964F8062B9B78F4005C193D /* PostPoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = B964F8052B9B78F4005C193D /* PostPoll.swift */; };
B97491E32B6E96700098BC48 /* SymbolWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97491E22B6E96700098BC48 /* SymbolWidth.swift */; };
B97798892B853E6600DC869F /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97798882B853E6600DC869F /* UpdateView.swift */; };
B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE232B3DD8400044756D /* HapticManager.swift */; };
@ -195,6 +196,7 @@
B93B67792B42EC51000892E9 /* MetaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaPicker.swift; sourceTree = "<group>"; };
B93B677B2B433A6E000892E9 /* PostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingView.swift; sourceTree = "<group>"; };
B95ED2362B87C9550055F5BD /* StoreKitTestCertificate.cer */ = {isa = PBXFileReference; lastKnownFileType = file; name = StoreKitTestCertificate.cer; path = ../../../../../Downloads/StoreKitTestCertificate.cer; sourceTree = "<group>"; };
B964F8052B9B78F4005C193D /* PostPoll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostPoll.swift; sourceTree = "<group>"; };
B97491E22B6E96700098BC48 /* SymbolWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolWidth.swift; sourceTree = "<group>"; };
B97798882B853E6600DC869F /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
B97BCE232B3DD8400044756D /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
@ -397,6 +399,7 @@
B9B63B242B44997400BBC82D /* QuotePostView.swift */,
B9BED5152B5D5E6500C9B715 /* PostInteractor.swift */,
B9BED5172B5D649C00C9B715 /* PostMenu.swift */,
B964F8052B9B78F4005C193D /* PostPoll.swift */,
B98F47952B645DF40092000F /* EmojiSelector.swift */,
);
path = Post;
@ -875,6 +878,7 @@
B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */,
B9029FC42B8125CE00AA9B68 /* HuggingFace.swift in Sources */,
B9BED51A2B5D662D00C9B715 /* ShareSheetController.swift in Sources */,
B964F8062B9B78F4005C193D /* PostPoll.swift in Sources */,
B93757112B7FB8D400652F91 /* AltClients.swift in Sources */,
B9BED5162B5D5E6500C9B715 /* PostInteractor.swift in Sources */,
B98BC74B2B46CF0400595441 /* ThreadedStyle.swift in Sources */,
@ -954,7 +958,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "ThreadedWatch Watch App.entitlements";
CODE_SIGN_ENTITLEMENTS = "ThreadedWatch/ThreadedWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"ThreadedWatch/Preview Content\"";
@ -984,7 +988,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "ThreadedWatch Watch App.entitlements";
CODE_SIGN_ENTITLEMENTS = "ThreadedWatch/ThreadedWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"ThreadedWatch/Preview Content\"";

View File

@ -127,6 +127,10 @@ struct CompactPostView: View {
}
}
if status.poll != nil {
PostPoll(poll: status.poll!)
}
if status.card != nil && status.mediaAttachments.isEmpty && !hasQuote {
PostCardView(card: status.card!)
}
@ -270,3 +274,11 @@ struct CompactPostView: View {
}
}
}
#Preview {
CompactPostView(status: Status.placeholder(forSettings: true, language: "fr"))
.environment(AccountManager())
.environment(UniversalNavigator())
.environmentObject(UserPreferences.defaultPreferences)
.environmentObject(Navigator())
}

View File

@ -0,0 +1,205 @@
//Made by Lumaa
import SwiftUI
struct PostPoll: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
@State var poll: Poll
@State private var selectedOption: [Int] = []
@State private var submitted: Bool = false
@State private var showResults: Bool = false
init(poll: Poll) {
self.poll = poll
}
var body: some View {
VStack(alignment: .leading) {
ForEach(poll.options) { option in
let index = poll.options.firstIndex(where: { $0.id == option.id })
let isMostVoted: Bool = self.isMostVoted(option: option)
let clamped: Double = Double(option.votesCount ?? 0) / Double(poll.safeVotersCount)
Button {
if !submitted && !poll.expired {
withAnimation(.spring(duration: 0.25)) {
selectVote(index ?? 0)
if !poll.multiple {
Task {
await vote()
}
}
}
}
} label: {
ZStack {
RoundedRectangle(cornerRadius: 15)
.stroke(Color.gray.opacity(0.4), lineWidth: 2.5)
.frame(width: 300, height: 50)
.overlay {
HStack {
Text(option.title)
.font(.subheadline)
.padding(.leading, 25)
.foregroundStyle(Color(uiColor: UIColor.label))
if selectedOption.contains(index ?? 0) {
Image(systemName: "checkmark.circle.fill")
.font(.subheadline)
.foregroundStyle(Color(uiColor: UIColor.label))
}
Spacer()
if showResults {
Text(String("\(Int(clamped * 100))%"))
.font(.subheadline)
.padding(.horizontal, 25)
.foregroundStyle(Color(uiColor: UIColor.label))
}
}
}
RoundedRectangle(cornerRadius: 15)
.fill(Color(uiColor: UIColor.label))
.frame(width: 300, height: 50)
.overlay {
HStack {
Text(option.title)
.foregroundStyle(Color(uiColor: UIColor.systemBackground))
.font(.subheadline)
.padding(.leading, 25)
if selectedOption.contains(index ?? 0) {
Image(systemName: "checkmark.circle.fill")
.font(.subheadline)
.foregroundStyle(Color(uiColor: UIColor.systemBackground))
}
Spacer()
if showResults {
Text(String("\(Int(clamped * 100))%"))
.font(.subheadline)
.padding(.horizontal, 25)
.foregroundStyle(Color(uiColor: UIColor.systemBackground))
}
}
}
.mask(alignment: .leading) {
RoundedRectangle(cornerRadius: 15)
.frame(width: showResults ? CGFloat(clamped * 300) : 0, height: 50)
}
}
.opacity((poll.expired || submitted) && !isMostVoted ? 0.5 : 1.0)
}
.buttonStyle(NoTapAnimationStyle())
}
if !poll.expired && !submitted {
HStack {
if poll.multiple {
Button {
Task {
await vote()
}
} label: {
Text("status.poll.submit")
}
.buttonStyle(LargeButton(filled: true, height: 7.5, disabled: selectedOption.count == 0))
.disabled(selectedOption.count == 0)
}
Button {
withAnimation(.spring) {
showResults.toggle()
}
} label: {
Text(showResults ? LocalizedStringKey("status.poll.hide-results") : LocalizedStringKey("status.poll.show-results"))
}
.buttonStyle(LargeButton(filled: false, height: 7.5))
}
}
HStack {
if !poll.expired {
Text("status.poll.expires-in.\(poll.expiresAt.value?.relativeFormatted ?? "unknown")")
.contentTransition(.numericText())
.font(.caption)
.multilineTextAlignment(.leading)
.foregroundStyle(Color.gray)
} else {
Text("status.poll.expired")
.font(.caption)
.multilineTextAlignment(.leading)
.foregroundStyle(Color.gray)
}
Spacer()
Text("status.poll.voters.\(poll.safeVotersCount)")
.font(.caption)
.multilineTextAlignment(.trailing)
.foregroundStyle(Color.gray)
}
.frame(width: 300)
}
.task {
await getPoll()
}
}
private func isMostVoted(option: Poll.Option) -> Bool {
let sortedOptions: [Poll.Option] = poll.options.sorted(by: { $0.votesCount ?? 0 > $1.votesCount ?? 0 })
let checkEquality: [Poll.Option] = sortedOptions.filter({ ($0.votesCount ?? 0) == (sortedOptions[0].votesCount ?? 0) })
let isMostVoted: Bool = checkEquality.contains(where: { $0.id == option.id })
return isMostVoted
}
private func getPoll() async {
guard let client = accountManager.getClient() else { return }
if let p: Poll = try? await client.get(endpoint: Polls.poll(id: poll.id)) {
poll = p
selectedOption = p.ownVotes ?? []
submitted = p.voted ?? true
showResults = submitted || p.expired
}
}
private func selectVote(_ index: Int) {
guard !poll.expired else { return }
if poll.multiple {
let remove: Bool = selectedOption.contains(index)
if remove {
selectedOption.removeAll(where: { $0 == index })
} else {
selectedOption.append(index)
}
} else {
selectedOption = [index]
}
}
private func vote() async {
guard let client = accountManager.getClient(), !poll.expired else { return }
_ = try? await client.post(endpoint: Polls.vote(id: poll.id, votes: selectedOption))
withAnimation(.spring) {
showResults = true
submitted = true
}
}
}
#Preview {
CompactPostView(status: Status.placeholder(forSettings: true, language: "fr"))
.environment(AccountManager())
.environment(UniversalNavigator())
.environmentObject(UserPreferences.defaultPreferences)
.environmentObject(Navigator())
}

View File

@ -71,7 +71,7 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable
public static func placeholder(forSettings: Bool = false, language: String? = nil) -> Status {
.init(id: UUID().uuidString,
content: .init(stringValue: "Here's to the [#crazy](#) ones",
content: .init(stringValue: "Have you ever tried [#Threaded](#)?",
parseMarkdown: forSettings),
account: .placeholder(),
@ -94,7 +94,7 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable
inReplyToId: nil,
inReplyToAccountId: nil,
visibility: .pub,
poll: nil,
poll: Poll.placeholder,
spoilerText: .init(stringValue: ""),
filtered: [],
sensitive: false,
@ -412,11 +412,20 @@ public struct Poll: Codable, Equatable, Hashable {
public var safeVotersCount: Int {
votersCount ?? votesCount
}
static let placeholder: Poll = Poll(id: "ABC", expiresAt: NullableString(), expired: false, multiple: true, votesCount: 3, votersCount: 3, voted: false, ownVotes: nil, options: [
Poll.Option.init(id: "ABC", title: "Option 1", votesCount: 1),
Poll.Option.init(id: "DEF", title: "Option 2", votesCount: 2)
])
}
public struct NullableString: Codable, Equatable, Hashable {
public let value: ServerDate?
init() {
self.value = ServerDate()
}
public init(from decoder: Decoder) throws {
do {
let container = try decoder.singleValueContainer()

View File

@ -680,3 +680,31 @@ public enum Conversations: Endpoint {
}
}
}
public enum Polls: Endpoint {
case poll(id: String)
case vote(id: String, votes: [Int])
public func path() -> String {
switch self {
case let .poll(id):
"polls/\(id)"
case let .vote(id, _):
"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

@ -2335,6 +2335,84 @@
}
}
},
"status.poll.expired" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expired"
}
}
}
},
"status.poll.expires-in.%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expires %@"
}
}
}
},
"status.poll.hide-results" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hide Results"
}
}
}
},
"status.poll.show-results" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show Results"
}
}
}
},
"status.poll.submit" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Submit"
}
}
}
},
"status.poll.voters.%lld" : {
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld vote"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld votes"
}
},
"zero" : {
"stringUnit" : {
"state" : "translated",
"value" : "No votes"
}
}
}
}
}
}
},
"status.posting" : {
"localizations" : {
"en" : {

View File

@ -90,6 +90,10 @@ struct PostDetailsView: View {
.id("\(detailedStatus.id)@\(detailedStatus.account.id)")
}
if status.poll != nil {
PostPoll(poll: status.poll!)
}
if status.card != nil && status.mediaAttachments.isEmpty {
PostCardView(card: status.card!)
}