From 0b92bb2ddc931c9e067ea74bab5fa11de5812461 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 1 Nov 2025 23:04:03 +0300 Subject: [PATCH 1/8] [WIP] Topic Poll --- .../AnalyticsClient/Events/TopicEvent.swift | 1 + Modules/Sources/Models/Forum/Topic.swift | 2 +- .../Analytics/TopicFeature+Analytics.swift | 3 + .../Resources/Localizable.xcstrings | 20 ++++ .../Sources/TopicFeature/TopicFeature.swift | 7 ++ .../Sources/TopicFeature/TopicScreen.swift | 29 +++++ .../Sources/TopicFeature/Views/PollView.swift | 106 ++++++++++++++++++ 7 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/TopicFeature/Views/PollView.swift diff --git a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index 89948f58..4a89bb40 100644 --- a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift @@ -10,6 +10,7 @@ import Foundation public enum TopicEvent: Event { case onRefresh case topicHatOpenButtonTapped + case topicPollOpenButtonTapped case userTapped(Int) case urlTapped(URL) case imageTapped(URL) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 3d369934..d9802c1c 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -41,7 +41,7 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { self.options = options } - public struct Choice: Sendable, Codable, Hashable { + public struct Choice: Sendable, Codable, Hashable, Identifiable { public let id: Int public let votes: Int public let name: String diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 11bfcbdd..ed47f037 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -41,6 +41,9 @@ extension TopicFeature { case .view(.topicHatOpenButtonTapped): analytics.log(TopicEvent.topicHatOpenButtonTapped) + case .view(.topicPollOpenButtonTapped): + analytics.log(TopicEvent.topicPollOpenButtonTapped) + case let .view(.userTapped(userId: userId)): analytics.log(TopicEvent.userTapped(userId)) diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 751eee45..b56f5783 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage" : "en", "strings" : { + "%lld people voted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проголосовало %lld чел." + } + } + } + }, "Add to favorites" : { "localizations" : { "ru" : { @@ -137,6 +147,16 @@ } } }, + "Poll" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опрос" + } + } + } + }, "Post deleted" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index bade73c4..65a94156 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -81,6 +81,7 @@ public struct TopicFeature: Reducer, Sendable { } var shouldShowTopicHatButton = false + var shouldShowTopicPollButton = true public init( topicId: Int, @@ -117,6 +118,7 @@ public struct TopicFeature: Reducer, Sendable { case onRefresh case finishedPostAnimation case topicHatOpenButtonTapped + case topicPollOpenButtonTapped case changeKarmaTapped(Int, Bool) case userTapped(Int) case urlTapped(URL) @@ -228,6 +230,10 @@ public struct TopicFeature: Reducer, Sendable { state.shouldShowTopicHatButton = false return .none + case .view(.topicPollOpenButtonTapped): + state.shouldShowTopicPollButton = false + return .none + case let .view(.userTapped(id)): return .send(.delegate(.openUser(id: id))) @@ -465,6 +471,7 @@ public struct TopicFeature: Reducer, Sendable { state.isLoadingTopic = false state.isRefreshing = false + state.shouldShowTopicPollButton = true state.shouldShowTopicHatButton = !state.pageNavigation.isFirstPage reportFullyDisplayed(&state) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index d0cc8a27..03e76d50 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -68,6 +68,9 @@ public struct TopicScreen: View { } if !store.isLoadingTopic { + if let poll = store.topic!.poll { + Poll(poll) + } PostList() } @@ -184,6 +187,32 @@ public struct TopicScreen: View { } } + // MARK: - Poll + + @ViewBuilder + private func Poll(_ poll: Topic.Poll) -> some View { + VStack(spacing: 0) { + if store.shouldShowTopicPollButton { + Button { + send(.topicPollOpenButtonTapped) + } label: { + Text("Poll", bundle: .module) + .font(.headline) + .bold() + .padding(16) + } + } else { + PollView(poll: poll, onVoteButtonTapped: { + // TODO: Implement... + }) + } + + Rectangle() + .foregroundStyle(Color(.Separator.post)) + .frame(height: 10) + } + } + // MARK: - Post List @ViewBuilder diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift new file mode 100644 index 00000000..930b964a --- /dev/null +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -0,0 +1,106 @@ +// +// PollView.swift +// ForPDA +// +// Created by Xialtal on 1.11.25. +// + +import SwiftUI +import Models + +struct PollView: View { + + @Environment(\.tintColor) private var tintColor + + let poll: Topic.Poll + let onVoteButtonTapped: () -> Void + + @State private var showVoteResultsButtonTapped = true + + init( + poll: Topic.Poll, + onVoteButtonTapped: @escaping () -> Void + ) { + self.poll = poll + self.onVoteButtonTapped = onVoteButtonTapped + } + + var body: some View { + VStack(spacing: 12) { + if !poll.name.isEmpty { + Text(poll.name) + .font(.headline) + .foregroundStyle(Color(.Labels.primary)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if showVoteResultsButtonTapped { + VStack(spacing: 12) { + ForEach(poll.options, id: \.self) { option in + VStack(spacing: 8) { + Text(option.name) + .font(.subheadline) + .foregroundStyle(Color(.Labels.primary)) + .frame(maxWidth: .infinity, alignment: .leading) + + OptionChoices(choices: option.choices) + } + } + } + + Text("\(poll.totalVotes) people voted", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + .frame(maxWidth: .infinity, alignment: .leading) + //.padding(.horizontal, 16) + } else { + // TODO: implement... + + } + } + .padding(16) + } + + // MARK: - Poll Option Choices + + @ViewBuilder + private func OptionChoices(choices: [Topic.Poll.Choice]) -> some View { + ForEach(choices, id: \.self) { choice in + VStack(spacing: 4) { + Text(choice.name) + .font(.caption) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack { + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(Color(.Background.teritary)) + .frame(height: 18) + + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(tintColor) + .frame( + width: (UIScreen.main.bounds.width - 32) * progressPercentage(choice, poll.totalVotes), + height: 18 + ) + + Spacer() + } + + Text(String("\(Int(progressPercentage(choice, poll.totalVotes) * 100))%")) + .font(.caption2) + .foregroundStyle(Color(.Labels.quaternary)) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.trailing, 4) + } + } + } + } + + // MARK: - Helpers + + private func progressPercentage(_ choice: Topic.Poll.Choice, _ totalVotes: Int) -> CGFloat { + return CGFloat(choice.votes) / CGFloat(totalVotes) + } +} From 70bec09f2497953d85199f6e7f9309344f5598a8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 2 Nov 2025 14:04:30 +0300 Subject: [PATCH 2/8] Extract topic poll mock --- Modules/Sources/Models/Forum/Topic.swift | 41 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index d9802c1c..86a3356b 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -110,20 +110,7 @@ public extension Topic { authorName: "4spander", curatorId: 6176341, curatorName: "AirFlare", - poll: Poll( - name: "Some simple poll...", - voted: false, - totalVotes: 2134, - options: [ - Poll.Option( - name: "Select this choise...", - several: false, - choices: [ - Poll.Choice(id: 2, name: "First choice", votes: 2) - ] - ) - ] - ), + poll: .mock, postsCount: 5005, posts: [ .mock(id: 0), .mock(id: 1), .mock(id: 2) @@ -133,3 +120,29 @@ public extension Topic { ] ) } + +public extension Topic.Poll { + static let mock = Topic.Poll( + name: "Some simple poll...", + voted: false, + totalVotes: 12, + options: [ + .init( + name: "Select not several...", + several: false, + choices: [ + .init(id: 2, name: "First choice", votes: 2), + .init(id: 3, name: "Second choice", votes: 4) + ] + ), + .init( + name: "Select several...", + several: true, + choices: [ + .init(id: 4, name: "First choice", votes: 4), + .init(id: 5, name: "Second choice", votes: 2) + ] + ), + ] + ) +} From fe432d7f742a9b4c167e5ae803d058c0bfd6eca5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 2 Nov 2025 14:06:35 +0300 Subject: [PATCH 3/8] WIP --- .../Resources/Localizable.xcstrings | 20 ++ .../Sources/TopicFeature/TopicScreen.swift | 2 +- .../Sources/TopicFeature/Views/PollView.swift | 189 ++++++++++++++++-- 3 files changed, 188 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index b56f5783..f73dadcd 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -247,6 +247,16 @@ } } }, + "Show results" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результаты" + } + } + } + }, "Today, %@" : { "localizations" : { "ru" : { @@ -287,6 +297,16 @@ } } }, + "Vote" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Голосовать" + } + } + } + }, "Write Post" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 03e76d50..9ad07b42 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -202,7 +202,7 @@ public struct TopicScreen: View { .padding(16) } } else { - PollView(poll: poll, onVoteButtonTapped: { + PollView(poll: poll, onVoteButtonTapped: { selections in // TODO: Implement... }) } diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index 930b964a..d9bf747c 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -7,19 +7,21 @@ import SwiftUI import Models +import SharedUI struct PollView: View { @Environment(\.tintColor) private var tintColor let poll: Topic.Poll - let onVoteButtonTapped: () -> Void + let onVoteButtonTapped: ([String: [Int]]) -> Void - @State private var showVoteResultsButtonTapped = true + @State private var showVoteResultsButtonTapped = false + @State private var selections: [String: Set] = [:] init( poll: Topic.Poll, - onVoteButtonTapped: @escaping () -> Void + onVoteButtonTapped: @escaping ([String: [Int]]) -> Void ) { self.poll = poll self.onVoteButtonTapped = onVoteButtonTapped @@ -34,34 +36,123 @@ struct PollView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if showVoteResultsButtonTapped { - VStack(spacing: 12) { - ForEach(poll.options, id: \.self) { option in - VStack(spacing: 8) { - Text(option.name) - .font(.subheadline) - .foregroundStyle(Color(.Labels.primary)) - .frame(maxWidth: .infinity, alignment: .leading) - + VStack(spacing: 12) { + ForEach(poll.options, id: \.self) { option in + VStack(spacing: 8) { + Text(option.name) + .font(.subheadline) + .foregroundStyle(Color(.Labels.primary)) + .frame(maxWidth: .infinity, alignment: .leading) + + if showVoteResultsButtonTapped { OptionChoices(choices: option.choices) + } else { + OptionChoicesSelect(option: option) } } } - - Text("\(poll.totalVotes) people voted", bundle: .module) - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) - .frame(maxWidth: .infinity, alignment: .leading) - //.padding(.horizontal, 16) - } else { - // TODO: implement... - } + + Text("\(poll.totalVotes) people voted", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + .frame(maxWidth: .infinity, alignment: .leading) + + PollActionButtons() } .padding(16) } - // MARK: - Poll Option Choices + // MARK: - Poll Action Buttons + + @ViewBuilder + private func PollActionButtons() -> some View { + HStack { + Button { + if showVoteResultsButtonTapped { + showVoteResultsButtonTapped = false + } else { + // TODO: Implement selection data sending... + } + } label: { + Text("Vote", bundle: .module) + .padding(.horizontal, 18) + .padding(.vertical, 9) + } + .foregroundStyle(tintColor) + .background(tintColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Spacer() + + if !showVoteResultsButtonTapped { + Button { + showVoteResultsButtonTapped = true + } label: { + Text("Show results", bundle: .module) + .padding(.horizontal, 18) + .padding(.vertical, 9) + } + .foregroundStyle(tintColor) + .background(tintColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + + // MARK: - Option Choices Select + + @ViewBuilder + private func OptionChoicesSelect(option: Topic.Poll.Option) -> some View { + ForEach(option.choices, id: \.self) { choice in + VStack(spacing: 4) { + HStack(alignment: .top, spacing: 11) { + if option.several { + Toggle(isOn: Binding( + get: { isSelected(option.name, choice.id) }, + set: { isSelected in + withAnimation { + updateMultiSelections(option.name, choice.id, isSelected) + } + } + )) {} + .toggleStyle(CheckBoxToggleStyle()) + } else { + Button { + withAnimation { + selections[option.name] = Set([choice.id]) + } + } label: { + if isSelected(option.name, choice.id) { + ZStack { + Circle() + .strokeBorder(Color(.Labels.quintuple)) + .frame(width: 22, height: 22) + + Circle() + .foregroundStyle(tintColor) + .frame(width: 12, height: 12) + } + .frame(width: 22, height: 22) + } else { + Circle() + .strokeBorder(Color(.Labels.quintuple)) + .frame(width: 22, height: 22) + } + } + } + + Text(choice.name) + .font(.callout) + .foregroundStyle(Color(.Labels.secondary)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + // MARK: - Option Choices Display @ViewBuilder private func OptionChoices(choices: [Topic.Poll.Choice]) -> some View { @@ -103,4 +194,58 @@ struct PollView: View { private func progressPercentage(_ choice: Topic.Poll.Choice, _ totalVotes: Int) -> CGFloat { return CGFloat(choice.votes) / CGFloat(totalVotes) } + + private func isSelected(_ option: String, _ choiceId: Int) -> Bool { + return if selections[option] != nil { + selections[option]!.contains(choiceId) + } else { false } + } + + private func updateMultiSelections(_ option: String, _ choiceId: Int, _ isSelected: Bool) { + if selections[option] != nil { + if isSelected { + selections[option]!.insert(choiceId) + } else { + selections[option]!.remove(choiceId) + } + } else { + selections[option] = Set([choiceId]) + } + } +} + +struct CheckBoxToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + Button(action: { + configuration.isOn.toggle() + }, label: { + if !configuration.isOn { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(.Separator.secondary), lineWidth: 1.5) + .frame(width: 22, height: 22) + } else { + RoundedRectangle(cornerRadius: 6) + .frame(width: 22, height: 22) + .overlay { + Image(systemSymbol: .checkmark) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.white)) + } + } + }) + + configuration.label + } + } +} + +#Preview { + VStack { + PollView(poll: .mock, onVoteButtonTapped: { selections in + + }) + } + .environment(\.tintColor, Color(.Theme.primary)) } From 1a51f24a5d95bfc531db5dce4f45f93a37b211fa Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 2 Nov 2025 14:11:27 +0300 Subject: [PATCH 4/8] Add topic poll vote endpoint --- Modules/Sources/APIClient/APIClient.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index e73ce80e..51b08005 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -63,6 +63,7 @@ public struct APIClient: Sendable { public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse public var deletePosts: @Sendable (_ postIds: [Int]) async throws -> Bool public var postKarma: @Sendable (_ postId: Int, _ isUp: Bool) async throws -> Bool + public var voteInTopicPoll: @Sendable (_ topicId: Int, _ selections: [[Int]]) async throws -> Bool // Favorites public var getFavorites: @Sendable (_ request: FavoritesRequest, _ policy: CachePolicy) async throws -> AsyncThrowingStream @@ -357,6 +358,13 @@ extension APIClient: DependencyKey { return status == 0 }, + voteInTopicPoll: { topicId, selections in + let command = ForumCommand.Topic.Poll.vote(topicId: topicId, selections: selections) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, + // MARK: - Favorites getFavorites: { request, policy in @@ -570,6 +578,9 @@ extension APIClient: DependencyKey { postKarma: { _, _ in return true }, + voteInTopicPoll: { _, _ in + return true + }, getFavorites: { _, _ in let (stream, continuation) = AsyncThrowingStream.makeStream(of: Favorite.self) continuation.yield(with: .success(.mock)) From a674971f2cf761d5ab79edd58e5143fff4a618a2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 2 Nov 2025 15:28:34 +0300 Subject: [PATCH 5/8] Add topic polls full support --- Modules/Sources/Models/Forum/Topic.swift | 8 ++- .../ParsingClient/Parsers/TopicParser.swift | 3 +- .../Analytics/TopicFeature+Analytics.swift | 2 + .../Resources/Localizable.xcstrings | 10 +++ .../Sources/TopicFeature/TopicFeature.swift | 23 ++++++ .../Sources/TopicFeature/TopicScreen.swift | 2 +- .../Sources/TopicFeature/Views/PollView.swift | 71 +++++++++++++------ 7 files changed, 93 insertions(+), 26 deletions(-) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 86a3356b..7141c557 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -53,12 +53,14 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { } } - public struct Option: Sendable, Codable, Hashable { + public struct Option: Sendable, Codable, Hashable, Identifiable { + public let id: Int public let name: String public let several: Bool public let choices: [Choice] - public init(name: String, several: Bool, choices: [Choice]) { + public init(id: Int, name: String, several: Bool, choices: [Choice]) { + self.id = id self.name = name self.several = several self.choices = choices @@ -128,6 +130,7 @@ public extension Topic.Poll { totalVotes: 12, options: [ .init( + id: 0, name: "Select not several...", several: false, choices: [ @@ -136,6 +139,7 @@ public extension Topic.Poll { ] ), .init( + id: 1, name: "Select several...", several: true, choices: [ diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index 6e22135a..e42fac01 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -131,7 +131,7 @@ public struct TopicParser { private static func parsePollOptions(_ optionsRaw: [[Any]]) throws(ParsingError) -> [Topic.Poll.Option] { var options: [Topic.Poll.Option] = [] - for option in optionsRaw { + for (idx, option) in optionsRaw.enumerated() { guard let name = option[safe: 0] as? String, let several = option[safe: 1] as? Int, let names = option[safe: 2] as? [String], @@ -151,6 +151,7 @@ public struct TopicParser { } let option = Topic.Poll.Option( + id: idx, name: name, several: several == 1 ? true : false, choices: choices diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index ed47f037..1f5736d6 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -23,10 +23,12 @@ extension TopicFeature { .view(.onNextAppear), .view(.finishedPostAnimation), .view(.changeKarmaTapped), + .view(.topicPollVoteButtonTapped), .internal(.loadTypes), .internal(.goToPost), .internal(.jumpRequestFailed), .internal(.changeKarma), + .internal(.voteInPoll), .internal(.load), .internal(.refresh), .pageNavigation, diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index f73dadcd..2f3c13fb 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -307,6 +307,16 @@ } } }, + "Vote approved" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Голос засчитан" + } + } + } + }, "Write Post" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 65a94156..0df05793 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -34,6 +34,7 @@ public struct TopicFeature: Reducer, Sendable { static let favoriteRemoved = LocalizedStringResource("Removed from favorites", bundle: .module) static let postDeleted = LocalizedStringResource("Post deleted", bundle: .module) static let postKarmaChanged = LocalizedStringResource("Post karma changed", bundle: .module) + static let topicVoteApproved = LocalizedStringResource("Vote approved", bundle: .module) } // MARK: - Destinations @@ -119,6 +120,7 @@ public struct TopicFeature: Reducer, Sendable { case finishedPostAnimation case topicHatOpenButtonTapped case topicPollOpenButtonTapped + case topicPollVoteButtonTapped([Int: Set]) case changeKarmaTapped(Int, Bool) case userTapped(Int) case urlTapped(URL) @@ -134,6 +136,7 @@ public struct TopicFeature: Reducer, Sendable { case refresh case goToPost(postId: Int, offset: Int, forceRefresh: Bool) case changeKarma(postId: Int, isUp: Bool) + case voteInPoll(selections: [[Int]]) case loadTopic(Int) case loadTypes([[TopicTypeUI]]) case topicResponse(Result) @@ -234,6 +237,12 @@ public struct TopicFeature: Reducer, Sendable { state.shouldShowTopicPollButton = false return .none + case .view(.topicPollVoteButtonTapped(let selections)): + let values = selections.sorted(by: { $0.key < $1.key }).map { + Array($0.value) + } + return .send(.internal(.voteInPoll(selections: values))) + case let .view(.userTapped(id)): return .send(.delegate(.openUser(id: id))) @@ -403,6 +412,20 @@ public struct TopicFeature: Reducer, Sendable { jumpTo(.post(id: postId), true, &state) ) + case .internal(.voteInPoll(let selections)): + return .concatenate( + .run { [topicId = state.topicId] _ in + let status = try await apiClient.voteInTopicPoll( + topicId: topicId, + selections: selections + ) + let voteApproved = ToastMessage(text: Localization.topicVoteApproved, haptic: .success) + await toastClient.showToast(status ? voteApproved : .whoopsSomethingWentWrong) + }.cancellable(id: CancelID.loading), + + .send(.internal(.refresh)) + ) + case let .internal(.loadTopic(offset)): if !state.isRefreshing { state.isLoadingTopic = true diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 9ad07b42..b7155f34 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -203,7 +203,7 @@ public struct TopicScreen: View { } } else { PollView(poll: poll, onVoteButtonTapped: { selections in - // TODO: Implement... + send(.topicPollVoteButtonTapped(selections)) }) } diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index d9bf747c..f7217259 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -14,14 +14,28 @@ struct PollView: View { @Environment(\.tintColor) private var tintColor let poll: Topic.Poll - let onVoteButtonTapped: ([String: [Int]]) -> Void + let onVoteButtonTapped: ([Int: Set]) -> Void + @State private var isSending = false @State private var showVoteResultsButtonTapped = false - @State private var selections: [String: Set] = [:] + @State private var selections: [Int: Set] = [:] + + private var isVotable: Bool { + for option in poll.options { + if selections[option.id] == nil { + return false + } + if selections[option.id] != nil, + selections[option.id]!.isEmpty { + return false + } + } + return true + } init( poll: Topic.Poll, - onVoteButtonTapped: @escaping ([String: [Int]]) -> Void + onVoteButtonTapped: @escaping ([Int: Set]) -> Void ) { self.poll = poll self.onVoteButtonTapped = onVoteButtonTapped @@ -44,7 +58,7 @@ struct PollView: View { .foregroundStyle(Color(.Labels.primary)) .frame(maxWidth: .infinity, alignment: .leading) - if showVoteResultsButtonTapped { + if showVoteResultsButtonTapped || poll.voted { OptionChoices(choices: option.choices) } else { OptionChoicesSelect(option: option) @@ -58,7 +72,9 @@ struct PollView: View { .foregroundStyle(Color(.Labels.teritary)) .frame(maxWidth: .infinity, alignment: .leading) - PollActionButtons() + if !poll.voted { + PollActionButtons() + } } .padding(16) } @@ -72,16 +88,18 @@ struct PollView: View { if showVoteResultsButtonTapped { showVoteResultsButtonTapped = false } else { - // TODO: Implement selection data sending... + isSending = true + onVoteButtonTapped(selections) } } label: { Text("Vote", bundle: .module) .padding(.horizontal, 18) .padding(.vertical, 9) } - .foregroundStyle(tintColor) - .background(tintColor.opacity(0.12)) + .foregroundStyle(voteButtonForegroundColor()) + .background(voteButtonBackgroundColor()) .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(!showVoteResultsButtonTapped && !isVotable) Spacer() @@ -93,9 +111,10 @@ struct PollView: View { .padding(.horizontal, 18) .padding(.vertical, 9) } - .foregroundStyle(tintColor) - .background(tintColor.opacity(0.12)) + .foregroundStyle(isSending ? Color(.Labels.quintuple) : tintColor) + .background(isSending ? Color(.Main.greyAlpha) : tintColor.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(isSending) } } } @@ -109,10 +128,10 @@ struct PollView: View { HStack(alignment: .top, spacing: 11) { if option.several { Toggle(isOn: Binding( - get: { isSelected(option.name, choice.id) }, + get: { isSelected(option.id, choice.id) }, set: { isSelected in withAnimation { - updateMultiSelections(option.name, choice.id, isSelected) + updateMultiSelections(option.id, choice.id, isSelected) } } )) {} @@ -120,10 +139,10 @@ struct PollView: View { } else { Button { withAnimation { - selections[option.name] = Set([choice.id]) + selections[option.id] = Set([choice.id]) } } label: { - if isSelected(option.name, choice.id) { + if isSelected(option.id, choice.id) { ZStack { Circle() .strokeBorder(Color(.Labels.quintuple)) @@ -195,21 +214,29 @@ struct PollView: View { return CGFloat(choice.votes) / CGFloat(totalVotes) } - private func isSelected(_ option: String, _ choiceId: Int) -> Bool { - return if selections[option] != nil { - selections[option]!.contains(choiceId) + private func voteButtonForegroundColor() -> Color { + return (!isVotable && !showVoteResultsButtonTapped || isSending) ? Color(.Labels.quintuple) : tintColor + } + + private func voteButtonBackgroundColor() -> Color { + return (!isVotable && !showVoteResultsButtonTapped || isSending) ? Color(.Main.greyAlpha) : tintColor.opacity(0.12) + } + + private func isSelected(_ optionId: Int, _ choiceId: Int) -> Bool { + return if selections[optionId] != nil { + selections[optionId]!.contains(choiceId) } else { false } } - private func updateMultiSelections(_ option: String, _ choiceId: Int, _ isSelected: Bool) { - if selections[option] != nil { + private func updateMultiSelections(_ optionId: Int, _ choiceId: Int, _ isSelected: Bool) { + if selections[optionId] != nil { if isSelected { - selections[option]!.insert(choiceId) + selections[optionId]!.insert(choiceId) } else { - selections[option]!.remove(choiceId) + selections[optionId]!.remove(choiceId) } } else { - selections[option] = Set([choiceId]) + selections[optionId] = Set([choiceId]) } } } From a4f0809c5e941d881fb2e73014282cdd3e1d2663 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 10 Nov 2025 16:23:41 +0300 Subject: [PATCH 6/8] Add topic poll animations --- Modules/Sources/TopicFeature/Views/PollView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index f7217259..b817af38 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -86,7 +86,9 @@ struct PollView: View { HStack { Button { if showVoteResultsButtonTapped { - showVoteResultsButtonTapped = false + withAnimation { + showVoteResultsButtonTapped = false + } } else { isSending = true onVoteButtonTapped(selections) @@ -105,7 +107,9 @@ struct PollView: View { if !showVoteResultsButtonTapped { Button { - showVoteResultsButtonTapped = true + withAnimation { + showVoteResultsButtonTapped = true + } } label: { Text("Show results", bundle: .module) .padding(.horizontal, 18) From ae738bf503ceecb7f9cb162c1a44c2f4ff0b5c2b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 10 Nov 2025 16:26:23 +0300 Subject: [PATCH 7/8] Merge topic hat and poll button into one section --- .../Sources/TopicFeature/TopicScreen.swift | 77 +++++++++++++------ .../Sources/TopicFeature/Views/PollView.swift | 2 +- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index b7155f34..7e2e2cea 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -45,6 +45,11 @@ public struct TopicScreen: View { return shouldShow && (!isLiquidGlass || !isAnyFloatingNavigationEnabled) } + private var isPollAvailable: Bool { + let topicLoaded = store.topic != nil && !store.isLoadingTopic + return topicLoaded && store.topic!.poll != nil + } + // MARK: - Init public init(store: StoreOf) { @@ -68,9 +73,12 @@ public struct TopicScreen: View { } if !store.isLoadingTopic { + Header() + if let poll = store.topic!.poll { Poll(poll) } + PostList() } @@ -187,29 +195,50 @@ public struct TopicScreen: View { } } - // MARK: - Poll + // MARK: - Header @ViewBuilder - private func Poll(_ poll: Topic.Poll) -> some View { - VStack(spacing: 0) { - if store.shouldShowTopicPollButton { + private func Header() -> some View { + HStack { + if isPollAvailable, store.shouldShowTopicPollButton { Button { send(.topicPollOpenButtonTapped) } label: { Text("Poll", bundle: .module) .font(.headline) .bold() - .padding(16) } - } else { + } + + if store.shouldShowTopicHatButton { + Button { + send(.topicHatOpenButtonTapped) + } label: { + Text("Topic Hat", bundle: .module) + .font(.headline) + .bold() + } + } + } + } + + // MARK: - Poll + + @ViewBuilder + private func Poll(_ poll: Topic.Poll) -> some View { + VStack(spacing: 0) { + if !store.shouldShowTopicPollButton { + if store.shouldShowTopicHatButton { + PostSeparator() + } + PollView(poll: poll, onVoteButtonTapped: { selections in send(.topicPollVoteButtonTapped(selections)) }) + .padding(.top, store.shouldShowTopicHatButton ? 16 : 0) } - Rectangle() - .foregroundStyle(Color(.Separator.post)) - .frame(height: 10) + PostSeparator() } } @@ -219,30 +248,30 @@ public struct TopicScreen: View { private func PostList() -> some View { ForEach(store.posts) { post in WithPerceptionTracking { - VStack(spacing: 0) { - if store.shouldShowTopicHatButton && store.posts.first == post { - Button { - send(.topicHatOpenButtonTapped) - } label: { - Text("Topic Hat", bundle: .module) - .font(.headline) - .bold() - .padding(16) - } - } else { + if store.shouldShowTopicHatButton && store.posts.first == post { + if !isPollAvailable { + PostSeparator() + } + } else { + VStack(spacing: 0) { Post(post) .padding(.horizontal, 16) .padding(.bottom, 16) + + PostSeparator() } - - Rectangle() - .foregroundStyle(Color(.Separator.post)) - .frame(height: 10) } } } } + @ViewBuilder + private func PostSeparator() -> some View { + Rectangle() + .foregroundStyle(Color(.Separator.post)) + .frame(height: 10) + } + // MARK: - Post @ViewBuilder diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index b817af38..a71f3f85 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -76,7 +76,7 @@ struct PollView: View { PollActionButtons() } } - .padding(16) + .padding([.bottom, .horizontal], 16) } // MARK: - Poll Action Buttons From 78c8079e4cd556efe49c115e87ee810254e3031e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 21 Nov 2025 21:34:23 +0300 Subject: [PATCH 8/8] Topic poll improvements --- .../ParsingClient/Parsers/TopicParser.swift | 4 ++-- .../Sources/TopicFeature/TopicFeature.swift | 4 ++-- Modules/Sources/TopicFeature/TopicScreen.swift | 2 +- .../Sources/TopicFeature/Views/PollView.swift | 18 +++++++++++++++--- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index e42fac01..f564efde 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -131,7 +131,7 @@ public struct TopicParser { private static func parsePollOptions(_ optionsRaw: [[Any]]) throws(ParsingError) -> [Topic.Poll.Option] { var options: [Topic.Poll.Option] = [] - for (idx, option) in optionsRaw.enumerated() { + for (optionId, option) in optionsRaw.enumerated() { guard let name = option[safe: 0] as? String, let several = option[safe: 1] as? Int, let names = option[safe: 2] as? [String], @@ -151,7 +151,7 @@ public struct TopicParser { } let option = Topic.Poll.Option( - id: idx, + id: optionId, name: name, several: several == 1 ? true : false, choices: choices diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 0df05793..81b19b16 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -237,7 +237,7 @@ public struct TopicFeature: Reducer, Sendable { state.shouldShowTopicPollButton = false return .none - case .view(.topicPollVoteButtonTapped(let selections)): + case let .view(.topicPollVoteButtonTapped(selections)): let values = selections.sorted(by: { $0.key < $1.key }).map { Array($0.value) } @@ -412,7 +412,7 @@ public struct TopicFeature: Reducer, Sendable { jumpTo(.post(id: postId), true, &state) ) - case .internal(.voteInPoll(let selections)): + case let .internal(.voteInPoll(selections)): return .concatenate( .run { [topicId = state.topicId] _ in let status = try await apiClient.voteInTopicPoll( diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 7e2e2cea..24e3a6fb 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -75,7 +75,7 @@ public struct TopicScreen: View { if !store.isLoadingTopic { Header() - if let poll = store.topic!.poll { + if let poll = store.topic?.poll { Poll(poll) } diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index a71f3f85..a6862bc9 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -11,15 +11,19 @@ import SharedUI struct PollView: View { - @Environment(\.tintColor) private var tintColor + // MARK: - Properties - let poll: Topic.Poll - let onVoteButtonTapped: ([Int: Set]) -> Void + @Environment(\.tintColor) private var tintColor @State private var isSending = false @State private var showVoteResultsButtonTapped = false @State private var selections: [Int: Set] = [:] + let poll: Topic.Poll + let onVoteButtonTapped: ([Int: Set]) -> Void + + // MARK: - Computed Properties + private var isVotable: Bool { for option in poll.options { if selections[option.id] == nil { @@ -33,6 +37,8 @@ struct PollView: View { return true } + // MARK: - Init + init( poll: Topic.Poll, onVoteButtonTapped: @escaping ([Int: Set]) -> Void @@ -41,6 +47,8 @@ struct PollView: View { self.onVoteButtonTapped = onVoteButtonTapped } + // MARK: - Body + var body: some View { VStack(spacing: 12) { if !poll.name.isEmpty { @@ -245,6 +253,8 @@ struct PollView: View { } } +// MARK: - CheckBox Toggle Style + struct CheckBoxToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { HStack { @@ -272,6 +282,8 @@ struct CheckBoxToggleStyle: ToggleStyle { } } +// MARK: - Previews + #Preview { VStack { PollView(poll: .mock, onVoteButtonTapped: { selections in