vector-im/element-ios/issues/5114 - Polls in the timeline.

This commit is contained in:
Stefan Ceriu
2021-11-19 17:40:52 +02:00
parent df2e73f096
commit dc574ab0a6
47 changed files with 1717 additions and 223 deletions
@@ -0,0 +1,143 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
/*
Copyright 2021 New Vector Ltd
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 SwiftUI
import MatrixSDK
import Combine
struct PollTimelineCoordinatorParameters {
let session: MXSession
let room: MXRoom
let pollStartEvent: MXEvent
}
@available(iOS 14.0, *)
final class PollTimelineCoordinator: Coordinator, PollAggregatorDelegate {
// MARK: - Properties
// MARK: Private
private let parameters: PollTimelineCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var pollTimelineViewModel: PollTimelineViewModel!
private var cancellables = Set<AnyCancellable>()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: PollTimelineCoordinatorParameters) throws {
self.parameters = parameters
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEvent: parameters.pollStartEvent)
pollAggregator.delegate = self
pollTimelineViewModel = PollTimelineViewModel(timelinePoll: buildTimelinePollFrom(pollAggregator.poll))
pollTimelineViewModel.callback = { [weak self] result in
guard let self = self else { return }
switch result {
case .selectedAnswerOptionsWithIdentifiers(let identifiers):
self.selectedAnswerIdentifiersSubject.send(identifiers)
}
}
selectedAnswerIdentifiersSubject
.debounce(for: 1.0, scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] identifiers in
guard let self = self else { return }
self.parameters.room.sendPollResponse(for: parameters.pollStartEvent,
withAnswerIdentifiers: identifiers,
localEcho: nil, success: nil) { [weak self] error in
guard let self = self else { return }
MXLog.error("[PollTimelineCoordinator]] Failed submitting response with error \(String(describing: error))")
self.pollTimelineViewModel.dispatch(action: .showAnsweringFailure)
}
}
.store(in: &cancellables)
}
// MARK: - Public
func start() {
}
func toPresentable() -> UIViewController {
return VectorHostingController(rootView: PollTimelineView(viewModel: pollTimelineViewModel.context))
}
func canEndPoll() -> Bool {
return pollAggregator.poll.isClosed == false
}
func endPoll() {
parameters.room.sendPollEnd(for: parameters.pollStartEvent, localEcho: nil, success: nil) { [weak self] error in
self?.pollTimelineViewModel.dispatch(action: .showClosingFailure)
}
}
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
pollTimelineViewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
}
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
}
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) {
}
// MARK: - Private
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePoll {
let answerOptions = poll.answerOptions.map { pollAnswerOption in
TimelineAnswerOption(id: pollAnswerOption.id,
text: pollAnswerOption.text,
count: pollAnswerOption.count,
winner: pollAnswerOption.isWinner,
selected: pollAnswerOption.isCurrentUserSelection)
}
return TimelinePoll(question: poll.text,
answerOptions: answerOptions,
closed: poll.isClosed,
totalAnswerCount: poll.totalAnswerCount,
type: (poll.kind == .disclosed ? .disclosed : .undisclosed),
maxAllowedSelections: poll.maxAllowedSelections)
}
}
@@ -0,0 +1,55 @@
//
// Copyright 2021 New Vector Ltd
//
// 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
@available(iOS 14, *)
class PollTimelineProvider {
static let shared = PollTimelineProvider()
var session: MXSession?
var coordinatorsForEventIdentifiers = [String: PollTimelineCoordinator]()
private init() {
}
/// Create or retrieve the poll timeline coordinator for this event and return
/// a view to be displayed in the timeline
func buildPollTimelineViewForEvent(_ event: MXEvent) -> UIView? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
return coordinator.toPresentable().view
}
let parameters = PollTimelineCoordinatorParameters(session: session, room: room, pollStartEvent: event)
guard let coordinator = try? PollTimelineCoordinator(parameters: parameters) else {
return nil
}
coordinatorsForEventIdentifiers[event.eventId] = coordinator
return coordinator.toPresentable().view
}
/// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
func pollTimelineCoordinatorForEventIdentifier(_ eventIdentifier: String) -> PollTimelineCoordinator? {
return coordinatorsForEventIdentifiers[eventIdentifier]
}
}
@@ -0,0 +1,91 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
// 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
typealias PollTimelineViewModelCallback = ((PollTimelineViewModelResult) -> Void)
enum PollTimelineStateAction {
case viewAction(PollTimelineViewAction, PollTimelineViewModelCallback?)
case updateWithPoll(TimelinePoll)
case showAnsweringFailure
case showClosingFailure
}
enum PollTimelineViewAction {
case selectAnswerOptionWithIdentifier(String)
}
enum PollTimelineViewModelResult {
case selectedAnswerOptionsWithIdentifiers([String])
}
enum TimelinePollType {
case disclosed
case undisclosed
}
class TimelineAnswerOption: Identifiable {
var id: String
var text: String
var count: UInt
var winner: Bool
var selected: Bool
init(id: String, text: String, count: UInt, winner: Bool, selected: Bool) {
self.id = id
self.text = text
self.count = count
self.winner = winner
self.selected = selected
}
}
class TimelinePoll {
var question: String
var answerOptions: [TimelineAnswerOption]
var closed: Bool
var totalAnswerCount: UInt
var type: TimelinePollType
var maxAllowedSelections: UInt
init(question: String, answerOptions: [TimelineAnswerOption], closed: Bool, totalAnswerCount: UInt, type: TimelinePollType, maxAllowedSelections: UInt) {
self.question = question
self.answerOptions = answerOptions
self.closed = closed
self.totalAnswerCount = totalAnswerCount
self.type = type
self.maxAllowedSelections = maxAllowedSelections
}
var hasCurrentUserVoted: Bool {
answerOptions.filter { $0.selected == true}.count > 0
}
}
struct PollTimelineViewState: BindableState {
var poll: TimelinePoll
var bindings: PollTimelineViewStateBindings
}
struct PollTimelineViewStateBindings {
var showsAnsweringFailureAlert: Bool = false
var showsClosingFailureAlert: Bool = false
}
@@ -0,0 +1,47 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
// 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
@available(iOS 14.0, *)
enum MockPollTimelineScreenState: MockScreenState, CaseIterable {
case open
case closed
var screenType: Any.Type {
MockPollTimelineScreenState.self
}
var screenView: ([Any], AnyView) {
let answerOptions = [TimelineAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelineAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelineAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let poll = TimelinePoll(question: "Question",
answerOptions: answerOptions,
closed: (self == .closed ? true : false),
totalAnswerCount: 20,
type: .disclosed,
maxAllowedSelections: 1)
let viewModel = PollTimelineViewModel(timelinePoll: poll)
return ([viewModel], AnyView(PollTimelineView(viewModel: viewModel.context)))
}
}
@@ -0,0 +1,132 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
// 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 SwiftUI
import Combine
@available(iOS 14, *)
typealias PollTimelineViewModelType = StateStoreViewModel<PollTimelineViewState,
PollTimelineStateAction,
PollTimelineViewAction>
@available(iOS 14, *)
class PollTimelineViewModel: PollTimelineViewModelType {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: PollTimelineViewModelCallback?
// MARK: - Setup
init(timelinePoll: TimelinePoll) {
super.init(initialViewState: PollTimelineViewState(poll: timelinePoll, bindings: PollTimelineViewStateBindings()))
}
// MARK: - Public
override func process(viewAction: PollTimelineViewAction) {
switch viewAction {
case .selectAnswerOptionWithIdentifier(_):
dispatch(action: .viewAction(viewAction, callback))
}
}
override class func reducer(state: inout PollTimelineViewState, action: PollTimelineStateAction) {
switch action {
case .viewAction(let viewAction, let callback):
switch viewAction {
// Update local state. An update will be pushed from the coordinator once sent.
case .selectAnswerOptionWithIdentifier(let identifier):
guard !state.poll.closed else {
return
}
if (state.poll.maxAllowedSelections == 1) {
updateSingleSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
} else {
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
}
}
case .updateWithPoll(let poll):
state.poll = poll
case .showAnsweringFailure:
state.bindings.showsAnsweringFailureAlert = true
case .showClosingFailure:
state.bindings.showsClosingFailureAlert = true
}
}
// MARK: - Private
static func updateSingleSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
for answerOption in state.poll.answerOptions {
if answerOption.selected {
answerOption.selected = false
if(answerOption.count > 0) {
answerOption.count = answerOption.count - 1
state.poll.totalAnswerCount -= 1
}
}
if answerOption.id == selectedAnswerIdentifier {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
}
}
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func updateMultiSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
if !isDeselecting && selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
return
}
for answerOption in state.poll.answerOptions where answerOption.id == selectedAnswerIdentifier {
if answerOption.selected {
answerOption.selected = false
answerOption.count -= 1
state.poll.totalAnswerCount -= 1
} else {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
}
}
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func informCoordinatorOfSelectionUpdate(state: PollTimelineViewState, callback: PollTimelineViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
}
}
@@ -0,0 +1,98 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
// 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 XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testOpenPoll() {
app.buttons[MockPollTimelineScreenState.screenStateKeys.first!].tap()
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
XCTAssert(app.buttons["First, 10 votes"].exists)
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
XCTAssert(app.buttons["Second, 5 votes"].exists)
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
XCTAssert(app.buttons["Third, 15 votes"].exists)
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
app.buttons["First, 10 votes"].tap()
XCTAssert(app.buttons["First, 11 votes"].exists)
XCTAssertEqual(app.buttons["First, 11 votes"].value as! String, "55%")
XCTAssert(app.buttons["Second, 4 votes"].exists)
XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
XCTAssert(app.buttons["Third, 15 votes"].exists)
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
app.buttons["Third, 15 votes"].tap()
XCTAssert(app.buttons["First, 10 votes"].exists)
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
XCTAssert(app.buttons["Second, 4 votes"].exists)
XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
XCTAssert(app.buttons["Third, 16 votes"].exists)
XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
}
func testClosedPoll() {
app.buttons[MockPollTimelineScreenState.screenStateKeys.last!].tap()
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["Final results based on 20 votes"].exists)
XCTAssert(app.buttons["First, 10 votes"].exists)
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
XCTAssert(app.buttons["Second, 5 votes"].exists)
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
XCTAssert(app.buttons["Third, 15 votes"].exists)
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
app.buttons["First, 10 votes"].tap()
XCTAssert(app.buttons["First, 10 votes"].exists)
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
XCTAssert(app.buttons["Second, 5 votes"].exists)
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
XCTAssert(app.buttons["Third, 15 votes"].exists)
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
}
}
@@ -0,0 +1,149 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
// 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 XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineViewModelTests: XCTestCase {
var viewModel: PollTimelineViewModel!
var context: PollTimelineViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
let answerOptions = [TimelineAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
TimelineAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
TimelineAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
let timelinePoll = TimelinePoll(question: "Question",
answerOptions: answerOptions,
closed: false,
totalAnswerCount: 3,
type: .disclosed,
maxAllowedSelections: 1)
viewModel = PollTimelineViewModel(timelinePoll: timelinePoll)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.poll.answerOptions.count, 3)
XCTAssertFalse(context.viewState.poll.closed)
XCTAssertEqual(context.viewState.poll.type, .disclosed)
}
func testSingleSelectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
func testSingleReselectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
func testMultipleSelectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
}
func testMultipleReselectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
}
func testClosedSelection() {
context.viewState.poll.closed = true
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
func testSingleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
func testSingleReselectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
func testMultipleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("2"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
context.send(viewAction: .selectAnswerOptionWithIdentifier("2"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
}
}
@@ -0,0 +1,155 @@
//
// Copyright 2021 New Vector Ltd
//
// 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 SwiftUI
@available(iOS 14.0, *)
struct PollTimelineAnswerOptionButton: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
let answerOption: TimelineAnswerOption
let pollClosed: Bool
let showResults: Bool
let totalAnswerCount: UInt
let action: () -> Void
// MARK: Public
var body: some View {
Button(action: action) {
let rect = RoundedRectangle(cornerRadius: 4.0)
answerOptionLabel
.padding(.horizontal, 8.0)
.padding(.top, 12.0)
.padding(.bottom, 4.0)
.clipShape(rect)
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
.accentColor(progressViewAccentColor)
}
}
var answerOptionLabel: some View {
VStack(alignment: .leading, spacing: 12.0) {
HStack(alignment: .top, spacing: 8.0) {
if !pollClosed {
Image(uiImage: answerOption.selected ? Asset.Images.pollCheckboxSelected.image : Asset.Images.pollCheckboxDefault.image)
}
Text(answerOption.text)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if pollClosed && answerOption.winner {
Spacer()
Image(uiImage: Asset.Images.pollWinnerIcon.image)
}
}
HStack {
ProgressView(value: Double(showResults ? answerOption.count : 0),
total: Double(totalAnswerCount))
.progressViewStyle(LinearProgressViewStyle())
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
.padding(.vertical, 8.0)
if (showResults) {
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
.font(theme.fonts.footnote)
.foregroundColor(pollClosed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
}
}
}
}
var borderAccentColor: Color {
guard !pollClosed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quinaryContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quinaryContent
}
var progressViewAccentColor: Color {
guard !pollClosed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quarterlyContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
}
}
@available(iOS 14.0, *)
struct PollTimelineAnswerOptionButton_Previews: PreviewProvider {
static let stateRenderer = MockPollTimelineScreenState.stateRenderer
static var previews: some View {
Group {
VStack {
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
count: 200, winner: false, selected: false),
pollClosed: false, showResults: true, totalAnswerCount: 1000, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
count: 200, winner: false, selected: false),
pollClosed: false, showResults: false, totalAnswerCount: 1000, action: {})
}
VStack {
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: true, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: true, selected: true),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
count: 200, winner: false, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
count: 200, winner: true, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
}
}
}
}
@@ -0,0 +1,116 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
// 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 SwiftUI
@available(iOS 14.0, *)
struct PollTimelineView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: PollTimelineViewModel.Context
var body: some View {
let poll = viewModel.viewState.poll
VStack(alignment: .leading, spacing: 16.0) {
Text(poll.question)
.font(theme.fonts.bodySB)
VStack(spacing: 24.0) {
ForEach(poll.answerOptions) { answerOption in
PollTimelineAnswerOptionButton(answerOption: answerOption,
pollClosed: poll.closed,
showResults: shouldDiscloseResults,
totalAnswerCount: poll.totalAnswerCount) {
viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
}
}
.alert(isPresented: $viewModel.showsClosingFailureAlert) {
Alert(title: Text(VectorL10n.pollTimelineNotClosedTitle),
message: Text(VectorL10n.pollTimelineNotClosedSubtitle),
dismissButton: .default(Text(VectorL10n.pollTimelineNotClosedAction)))
}
}
.disabled(poll.closed)
.fixedSize(horizontal: false, vertical: true)
Text(totalVotesString)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
.alert(isPresented: $viewModel.showsAnsweringFailureAlert) {
Alert(title: Text(VectorL10n.pollTimelineVoteNotRegisteredTitle),
message: Text(VectorL10n.pollTimelineVoteNotRegisteredSubtitle),
dismissButton: .default(Text(VectorL10n.pollTimelineVoteNotRegisteredAction)))
}
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])
}
private var totalVotesString: String {
let poll = viewModel.viewState.poll
if poll.closed {
if poll.totalAnswerCount == 1 {
return VectorL10n.pollTimelineTotalFinalResultsOneVote
} else {
return VectorL10n.pollTimelineTotalFinalResults(Int(poll.totalAnswerCount))
}
}
switch poll.totalAnswerCount {
case 0:
return VectorL10n.pollTimelineTotalNoVotes
case 1:
return (poll.hasCurrentUserVoted ?
VectorL10n.pollTimelineTotalOneVote :
VectorL10n.pollTimelineTotalOneVoteNotVoted)
default:
return (poll.hasCurrentUserVoted ?
VectorL10n.pollTimelineTotalVotes(Int(poll.totalAnswerCount)) :
VectorL10n.pollTimelineTotalVotesNotVoted(Int(poll.totalAnswerCount)))
}
}
private var shouldDiscloseResults: Bool {
let poll = viewModel.viewState.poll
if poll.closed {
return poll.totalAnswerCount > 0
} else {
return poll.type == .disclosed && poll.totalAnswerCount > 0 && poll.hasCurrentUserVoted
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct PollTimelineView_Previews: PreviewProvider {
static let stateRenderer = MockPollTimelineScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}