Feature/4383 poll participants details

This commit is contained in:
Frank Rotermund
2023-05-31 14:31:07 +00:00
committed by Arnfried Griesert
parent 00286183f6
commit 9bd569dac0
22 changed files with 462 additions and 19 deletions

View File

@@ -125,6 +125,7 @@ class BWIBuildSettings: NSObject {
var bwiShowClosedPolls = true
var bwiPollShowParticipantsToggle = true
var bwiPollVisibleVotes = 5
var bwiShowThreads = false
var bwiShowRoomCreationSectionFooter = false

View File

@@ -43,7 +43,7 @@ when String # specific MatrixSDK released version
$matrixSDKVersionSpec = $matrixSDKVersion
end
$matrixSDKVersionSpec = { :git => 'https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk', :tag => 'v0.26.9_bwi_beta_2' }
$matrixSDKVersionSpec = { :git => 'https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk', :tag => 'v0.26.9_bwi_4383' }
# Method to import the MatrixSDK
def import_MatrixSDK

View File

@@ -541,6 +541,9 @@
"poll_edit_form_poll_type_closed" = "Versteckte Umfrage";
"poll_edit_form_poll_type_open" = "Offene Umfrage";
"poll_edit_form_participant_toggle" = "Anzeigen, wer für welche Option gestimmt hat.";
"poll_timeline_show_participants_button" = "Stimmen anzeigen";
"poll_participant_details_show_more" = "Alle ansehen (%lu weitere)";
"poll_participant_details_title" = "Umfragedetails";
// MARK: - Welcome Experience
"welcome_experience_title1" = "Willkommen beim BundesMessenger";

View File

@@ -446,6 +446,9 @@
"poll_edit_form_poll_type_closed" = "Hidden Poll";
"poll_edit_form_poll_type_open" = "Open poll";
"poll_edit_form_participant_toggle" = "Show who voted for which option";
"poll_timeline_show_participants_button" = "Show votes";
"poll_participant_details_show_more" = "Show all (%lu more)";
"poll_participant_details_title" = "Poll details";
// MARK: - Welcome Experience
"welcome_experience_title1" = "Welcome to BundesMessenger";

View File

@@ -983,6 +983,18 @@ public class BWIL10n: NSObject {
public static var pollEditFormPollTypeOpen: String {
return BWIL10n.tr("Bwi", "poll_edit_form_poll_type_open")
}
/// Alle ansehen (%lu weitere)
public static func pollParticipantDetailsShowMore(_ p1: Int) -> String {
return BWIL10n.tr("Bwi", "poll_participant_details_show_more", p1)
}
/// Umfragedetails
public static var pollParticipantDetailsTitle: String {
return BWIL10n.tr("Bwi", "poll_participant_details_title")
}
/// Stimmen anzeigen
public static var pollTimelineShowParticipantsButton: String {
return BWIL10n.tr("Bwi", "poll_timeline_show_participants_button")
}
/// Wiederholen
public static var retry: String {
return BWIL10n.tr("Bwi", "retry")

View File

@@ -143,6 +143,12 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
navigationRouter.setRootModule(self, popCompletion: nil)
}
}
// FRROT its like sleep() again -> hopefully there is a better solution. When directly calling self.navigation router here its still nil and it needs to be called this early because soon afterwards the pollCells get build
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
TimelinePollProvider.shared.navigationRouter = self.navigationRouter
}
}
func start(withEventId eventId: String, completion: (() -> Void)?) {

View File

@@ -42,7 +42,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
init(parameters: PollEditFormCoordinatorParameters) {
self.parameters = parameters
var viewModel: PollEditFormViewModel
if let startEvent = parameters.pollStartEvent,
let pollContent = MXEventContentPollStart(fromJSON: startEvent.content) {

View File

@@ -29,9 +29,9 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
}
var poll: TimelinePollDetails {
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false, voters:[]),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true, voters:[]),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false, voters:[])]
let poll = TimelinePollDetails(id: "id",
question: "Question",

View File

@@ -70,7 +70,7 @@ private extension MockPollHistoryService {
.map { index in
TimelinePollDetails(id: "p\(index)",
question: "Do you like the active poll number \(index)?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true)],
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true, voters: [])],
closed: true,
startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24),
totalAnswerCount: 30,

View File

@@ -85,7 +85,7 @@ struct PollListItem_Previews: PreviewProvider {
Group {
let pollData1 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true, voters:[])],
closed: true,
startDate: .init(),
totalAnswerCount: 30,
@@ -98,7 +98,7 @@ struct PollListItem_Previews: PreviewProvider {
let pollData2 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true, voters:[])],
closed: false,
startDate: .init(),
totalAnswerCount: 30,
@@ -112,8 +112,8 @@ struct PollListItem_Previews: PreviewProvider {
let pollData3 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [
.init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true),
.init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true)
.init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true, voters:[]),
.init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true, voters:[])
],
closed: true,
startDate: .init(),

View File

@@ -29,6 +29,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
// MARK: Private
private let navigationRouter: NavigationRouterType?
private let parameters: TimelinePollCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
@@ -43,9 +44,12 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
// MARK: - Setup
init(parameters: TimelinePollCoordinatorParameters) throws {
// FRROT show participants needs a navigation router as it is a button click that creates a new View
init(parameters: TimelinePollCoordinatorParameters, navigationRouter: NavigationRouterType? = nil) throws {
self.parameters = parameters
self.navigationRouter = navigationRouter
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent)
pollAggregator.delegate = self
@@ -56,6 +60,9 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
switch result {
case .selectedAnswerOptionsWithIdentifiers(let identifiers):
self.selectedAnswerIdentifiersSubject.send(identifiers)
case .showParticipants:
self.showParticipantsView()
}
}
@@ -105,6 +112,23 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
}
}
func showParticipantsView() {
if let navigationRouter = navigationRouter {
let parameters = PollParticipantDetailsCoordinatorParameters(room: parameters.room, poll: pollAggregator.poll)
let coordinator = PollParticipantDetailsCoordinator(parameters: parameters)
add(childCoordinator: coordinator)
if navigationRouter.modules.isEmpty == false {
navigationRouter.push(coordinator, animated: true, popCompletion: nil)
} else {
navigationRouter.setRootModule(coordinator, popCompletion: nil)
}
coordinator.start()
}
}
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
@@ -134,7 +158,8 @@ extension TimelinePollDetails {
text: pollAnswerOption.text,
count: pollAnswerOption.count,
winner: pollAnswerOption.isWinner,
selected: pollAnswerOption.isCurrentUserSelection)
selected: pollAnswerOption.isCurrentUserSelection,
voters:pollAnswerOption.voters)
}
self.init(id: poll.id,

View File

@@ -31,11 +31,15 @@ class TimelinePollProvider: NSObject {
}
}
}
var navigationRouter: NavigationRouterType? = nil
var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]()
/// Create or retrieve the poll timeline coordinator for this event and return
/// a view to be displayed in the timeline
func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
@@ -45,7 +49,7 @@ class TimelinePollProvider: NSObject {
}
let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollEvent: event)
guard let coordinator = try? TimelinePollCoordinator(parameters: parameters) else {
guard let coordinator = try? TimelinePollCoordinator(parameters: parameters, navigationRouter: navigationRouter ) else {
return messageViewController(for: event)
}

View File

@@ -26,6 +26,7 @@ enum TimelinePollViewAction {
enum TimelinePollViewModelResult {
case selectedAnswerOptionsWithIdentifiers([String])
case showParticipants
}
enum TimelinePollType {
@@ -44,13 +45,15 @@ struct TimelinePollAnswerOption: Identifiable {
var count: UInt
var winner: Bool
var selected: Bool
var voters: [MXEvent]
init(id: String, text: String, count: UInt, winner: Bool, selected: Bool) {
init(id: String, text: String, count: UInt, winner: Bool, selected: Bool, voters: [MXEvent]) {
self.id = id
self.text = text
self.count = count
self.winner = winner
self.selected = selected
self.voters = voters
}
}

View File

@@ -29,9 +29,9 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
}
var screenView: ([Any], AnyView) {
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false, voters:[]),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true, voters:[]),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false, voters:[])]
let poll = TimelinePollDetails(id: "id",
question: "Question",

View File

@@ -50,7 +50,7 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion)
}
case .showParticipants:
break
completion?(.showParticipants)
}
}

View File

@@ -169,6 +169,6 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
}
static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption {
TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected)
TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected, voters:[])
}
}

View File

@@ -58,6 +58,19 @@ struct TimelinePollView: View {
.lineLimit(2)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
if poll.showParticipants && (poll.type == .undisclosed || poll.closed) {
Button(action: {
viewModel.send(viewAction:.showParticipants)
})
{
Text(BWIL10n.pollTimelineShowParticipantsButton)
.font(theme.fonts.body)
.bold()
.foregroundColor(theme.colors.accent)
}
}
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])

View File

@@ -0,0 +1,68 @@
//
/*
* Copyright (c) 2022 BWI GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
import SwiftUI
import UIKit
struct PollParticipantDetailsCoordinatorParameters {
let room: MXRoom
let poll: PollProtocol
}
final class PollParticipantDetailsCoordinator: Coordinator, Presentable {
// MARK: Private
private let parameters: PollParticipantDetailsCoordinatorParameters
private let pollParticipantDetailsHostingController: UIViewController
private var pollParticipantDetailsViewModel: PollParticipantDetailsViewModelProtocol
// MARK: Public
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
init(parameters: PollParticipantDetailsCoordinatorParameters) {
self.parameters = parameters
var viewModel: PollParticipantDetailsViewModel
viewModel = PollParticipantDetailsViewModel.init(parameters: PollParticipantDetailsViewModelParameters(poll: parameters.poll, room: parameters.room))
let view = PollParticipantDetailsView(viewModel: viewModel.context)
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.room.mxSession.mediaManager)))
pollParticipantDetailsViewModel = viewModel
pollParticipantDetailsHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
}
// MARK: - Presentable
func toPresentable() -> UIViewController {
pollParticipantDetailsHostingController
}
}

View File

@@ -0,0 +1,96 @@
//
/*
* Copyright (c) 2022 BWI GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
struct PollParticipantDetailsViewState: BindableState {
var answers: [PollParticipantAnswer] = []
var poll: PollParticipantPoll
}
enum PollParticipantDetailsMode {
case someParticipants
case allParticipants
}
enum PollParticipantDetailsViewAction {
case openAllParticipants(index: Int)
case closeAllParticipants(index: Int)
}
struct PollParticipantVoter: Identifiable, BindableState {
var id: String {
displayName
}
var displayName: String
var userAvatarData: AvatarInputProtocol
var formattedVotingTime: String
static func buildPollParticipantVoter( event: MXEvent, room: MXRoom) -> PollParticipantVoter? {
if let user = room.mxSession.user(withUserId: event.sender) {
let avatarData = AvatarInput(mxContentUri: user.avatarUrl, matrixItemId: event.sender, displayName: user.displayname)
let votingTime = Date(timeIntervalSince1970: TimeInterval(event.originServerTs / 1000))
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone.current
dateFormatter.calendar = Calendar.current
dateFormatter.dateFormat = "dd. MMMM, yyyy HH:mm"
let strDate = dateFormatter.string(from: votingTime)
return PollParticipantVoter(displayName: user.displayname, userAvatarData: avatarData, formattedVotingTime: strDate)
} else {
return nil
}
}
}
struct PollParticipantAnswer: Identifiable, BindableState {
var id: String {
originalId
}
var name: String
var votes: Int
var visibleVotes: Int
var votesText: String
var originalId: String
var voters: [PollParticipantVoter]
var expanded: Bool = false
static func buildPollParticipantAnswer( answerOption: PollAnswerOptionProtocol, parameters: PollParticipantDetailsViewModelParameters) -> PollParticipantAnswer {
var voters: [PollParticipantVoter] = []
for participantEvent in answerOption.voters {
if let voter = PollParticipantVoter.buildPollParticipantVoter(event: participantEvent, room: parameters.room) {
voters.append(voter)
}
}
let votesText = answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count))
return PollParticipantAnswer.init( name: answerOption.text,
votes: Int(answerOption.count),
visibleVotes: min(Int(answerOption.count), BWIBuildSettings.shared.bwiPollVisibleVotes),
votesText: votesText,
originalId: answerOption.id,
voters: voters)
}
}
struct PollParticipantPoll : BindableState {
var name: String
let voterRows: Int = 2
}

View File

@@ -0,0 +1,121 @@
//
/*
* Copyright (c) 2022 BWI GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
import SwiftUI
struct PollParticipantDetailsView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: PollParticipantDetailsViewModel.Context
var body: some View {
NavigationView {
VStack {
PollParticipantPollHeaderView(poll: viewModel.viewState.poll)
List {
ForEach(Array(viewModel.viewState.answers.enumerated()), id: \.offset) { index, answer in
SwiftUI.Section(header: PollParticipantSectionHeaderView(answer: answer)) {
if answer.votes > 0 {
VStack {
ForEach(answer.voters.prefix(upTo: answer.visibleVotes)) { voter in
PollParticipantVoterView(voter: voter)
}
if answer.votes > answer.visibleVotes && !answer.expanded {
Button(action: { onExpandButton(index: index) }) {
Text(BWIL10n.pollParticipantDetailsShowMore(Int(answer.votes-answer.visibleVotes)))
}
}
}
}
}
}
}
.listStyle(.grouped)
}
}
.accentColor(theme.colors.accent)
.navigationViewStyle(StackNavigationViewStyle())
.navigationTitle(BWIL10n.pollParticipantDetailsTitle)
.navigationBarTitleDisplayMode(.inline)
}
private func onExpandButton(index: Int) {
viewModel.send(viewAction: .openAllParticipants(index: index))
}
}
struct PollParticipantVoterView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
var voter: PollParticipantVoter
var body: some View {
HStack(alignment: .center, spacing: 10) {
AvatarImage(avatarData: voter.userAvatarData, size: .medium)
.border()
VStack(alignment: .leading, spacing: 3) {
Text(voter.displayName)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
Text(voter.formattedVotingTime)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
}
}
}
}
struct PollParticipantPollHeaderView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
var poll: PollParticipantPoll
var body: some View {
Text(poll.name)
.font(theme.fonts.headline)
.foregroundColor(theme.colors.primaryContent)
}
}
struct PollParticipantSectionHeaderView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
var answer: PollParticipantAnswer
var body: some View {
HStack(alignment: .center) {
Text(answer.name)
.font(theme.fonts.headline)
.foregroundColor(theme.colors.primaryContent)
Spacer()
Text(answer.votesText)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.accessibilityIdentifier("PollAnswerOptionCount")
}
}
}

View File

@@ -0,0 +1,66 @@
//
/*
* Copyright (c) 2022 BWI GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
import Combine
import SwiftUI
struct PollParticipantDetailsViewModelParameters {
let poll: PollProtocol
let room: MXRoom
}
typealias PollParticipantDetailsViewModelType = StateStoreViewModel<PollParticipantDetailsViewState, PollParticipantDetailsViewAction>
class PollParticipantDetailsViewModel: PollParticipantDetailsViewModelType, PollParticipantDetailsViewModelProtocol {
init(parameters: PollParticipantDetailsViewModelParameters) {
var state = PollParticipantDetailsViewState(poll: PollParticipantPoll(name: parameters.poll.text))
var answers: [PollParticipantAnswer] = []
let room = parameters.room
for answerOption in parameters.poll.answerOptions {
let answer = PollParticipantAnswer.buildPollParticipantAnswer(answerOption: answerOption, parameters: parameters)
answers.append(answer)
}
answers.sort {
$0.votes > $1.votes
}
state.answers = answers
super.init(initialViewState: state)
}
// MARK: - Public
override func process(viewAction: PollParticipantDetailsViewAction) {
switch viewAction {
case .openAllParticipants(index: let index):
state.answers[index].expanded = true
state.answers[index].visibleVotes = state.answers[index].votes
case .closeAllParticipants(index: let index):
state.answers[index].expanded = false
state.answers[index].visibleVotes = min(state.answers[index].votes, state.poll.voterRows)
}
}
}

View File

@@ -0,0 +1,22 @@
//
/*
* Copyright (c) 2022 BWI GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Foundation
protocol PollParticipantDetailsViewModelProtocol {
}