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)) 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..7141c557 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 @@ -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 @@ -110,20 +112,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 +122,31 @@ public extension Topic { ] ) } + +public extension Topic.Poll { + static let mock = Topic.Poll( + name: "Some simple poll...", + voted: false, + totalVotes: 12, + options: [ + .init( + id: 0, + name: "Select not several...", + several: false, + choices: [ + .init(id: 2, name: "First choice", votes: 2), + .init(id: 3, name: "Second choice", votes: 4) + ] + ), + .init( + id: 1, + name: "Select several...", + several: true, + choices: [ + .init(id: 4, name: "First choice", votes: 4), + .init(id: 5, name: "Second choice", votes: 2) + ] + ), + ] + ) +} diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index 6e22135a..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 option in optionsRaw { + 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,6 +151,7 @@ public struct TopicParser { } let option = Topic.Poll.Option( + id: optionId, 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 11bfcbdd..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, @@ -41,6 +43,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..2f3c13fb 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" : { @@ -227,6 +247,16 @@ } } }, + "Show results" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результаты" + } + } + } + }, "Today, %@" : { "localizations" : { "ru" : { @@ -267,6 +297,26 @@ } } }, + "Vote" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Голосовать" + } + } + } + }, + "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 bade73c4..81b19b16 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 @@ -81,6 +82,7 @@ public struct TopicFeature: Reducer, Sendable { } var shouldShowTopicHatButton = false + var shouldShowTopicPollButton = true public init( topicId: Int, @@ -117,6 +119,8 @@ public struct TopicFeature: Reducer, Sendable { case onRefresh case finishedPostAnimation case topicHatOpenButtonTapped + case topicPollOpenButtonTapped + case topicPollVoteButtonTapped([Int: Set]) case changeKarmaTapped(Int, Bool) case userTapped(Int) case urlTapped(URL) @@ -132,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) @@ -228,6 +233,16 @@ public struct TopicFeature: Reducer, Sendable { state.shouldShowTopicHatButton = false return .none + case .view(.topicPollOpenButtonTapped): + state.shouldShowTopicPollButton = false + return .none + + case let .view(.topicPollVoteButtonTapped(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))) @@ -397,6 +412,20 @@ public struct TopicFeature: Reducer, Sendable { jumpTo(.post(id: postId), true, &state) ) + case let .internal(.voteInPoll(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 @@ -465,6 +494,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..24e3a6fb 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,6 +73,12 @@ public struct TopicScreen: View { } if !store.isLoadingTopic { + Header() + + if let poll = store.topic?.poll { + Poll(poll) + } + PostList() } @@ -184,36 +195,83 @@ public struct TopicScreen: View { } } + // MARK: - Header + + @ViewBuilder + private func Header() -> some View { + HStack { + if isPollAvailable, store.shouldShowTopicPollButton { + Button { + send(.topicPollOpenButtonTapped) + } label: { + Text("Poll", bundle: .module) + .font(.headline) + .bold() + } + } + + 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) + } + + PostSeparator() + } + } + // MARK: - Post List @ViewBuilder 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 new file mode 100644 index 00000000..a6862bc9 --- /dev/null +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -0,0 +1,294 @@ +// +// PollView.swift +// ForPDA +// +// Created by Xialtal on 1.11.25. +// + +import SwiftUI +import Models +import SharedUI + +struct PollView: View { + + // MARK: - Properties + + @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 { + return false + } + if selections[option.id] != nil, + selections[option.id]!.isEmpty { + return false + } + } + return true + } + + // MARK: - Init + + init( + poll: Topic.Poll, + onVoteButtonTapped: @escaping ([Int: Set]) -> Void + ) { + self.poll = poll + self.onVoteButtonTapped = onVoteButtonTapped + } + + // MARK: - Body + + 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) + } + + 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 || poll.voted { + 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) + + if !poll.voted { + PollActionButtons() + } + } + .padding([.bottom, .horizontal], 16) + } + + // MARK: - Poll Action Buttons + + @ViewBuilder + private func PollActionButtons() -> some View { + HStack { + Button { + if showVoteResultsButtonTapped { + withAnimation { + showVoteResultsButtonTapped = false + } + } else { + isSending = true + onVoteButtonTapped(selections) + } + } label: { + Text("Vote", bundle: .module) + .padding(.horizontal, 18) + .padding(.vertical, 9) + } + .foregroundStyle(voteButtonForegroundColor()) + .background(voteButtonBackgroundColor()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(!showVoteResultsButtonTapped && !isVotable) + + Spacer() + + if !showVoteResultsButtonTapped { + Button { + withAnimation { + showVoteResultsButtonTapped = true + } + } label: { + Text("Show results", bundle: .module) + .padding(.horizontal, 18) + .padding(.vertical, 9) + } + .foregroundStyle(isSending ? Color(.Labels.quintuple) : tintColor) + .background(isSending ? Color(.Main.greyAlpha) : tintColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(isSending) + } + } + } + + // 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.id, choice.id) }, + set: { isSelected in + withAnimation { + updateMultiSelections(option.id, choice.id, isSelected) + } + } + )) {} + .toggleStyle(CheckBoxToggleStyle()) + } else { + Button { + withAnimation { + selections[option.id] = Set([choice.id]) + } + } label: { + if isSelected(option.id, 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 { + 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) + } + + 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(_ optionId: Int, _ choiceId: Int, _ isSelected: Bool) { + if selections[optionId] != nil { + if isSelected { + selections[optionId]!.insert(choiceId) + } else { + selections[optionId]!.remove(choiceId) + } + } else { + selections[optionId] = Set([choiceId]) + } + } +} + +// MARK: - CheckBox Toggle Style + +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 + } + } +} + +// MARK: - Previews + +#Preview { + VStack { + PollView(poll: .mock, onVoteButtonTapped: { selections in + + }) + } + .environment(\.tintColor, Color(.Theme.primary)) +}