mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-23 18:12:44 +02:00
Merge branch 'develop' into ismail/5068_start_thread
This commit is contained in:
@@ -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
|
||||
import MatrixSDK
|
||||
import Combine
|
||||
|
||||
struct TimelinePollCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let room: MXRoom
|
||||
let pollStartEvent: MXEvent
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: TimelinePollCoordinatorParameters
|
||||
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
|
||||
|
||||
private var pollAggregator: PollAggregator
|
||||
private var viewModel: TimelinePollViewModel!
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
init(parameters: TimelinePollCoordinatorParameters) throws {
|
||||
self.parameters = parameters
|
||||
|
||||
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEventId: parameters.pollStartEvent.eventId)
|
||||
pollAggregator.delegate = self
|
||||
|
||||
viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll))
|
||||
viewModel.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,
|
||||
threadId: nil,
|
||||
localEcho: nil, success: nil) { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
MXLog.error("[TimelinePollCoordinator]] Failed submitting response with error \(String(describing: error))")
|
||||
|
||||
self.viewModel.dispatch(action: .showAnsweringFailure)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
|
||||
}
|
||||
|
||||
func canEndPoll() -> Bool {
|
||||
return pollAggregator.poll.isClosed == false
|
||||
}
|
||||
|
||||
func canEditPoll() -> Bool {
|
||||
return false // Intentionally disabled until platform parity.
|
||||
// return pollAggregator.poll.isClosed == false && pollAggregator.poll.totalAnswerCount == 0
|
||||
}
|
||||
|
||||
func endPoll() {
|
||||
parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in
|
||||
self?.viewModel.dispatch(action: .showClosingFailure)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PollAggregatorDelegate
|
||||
|
||||
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
|
||||
viewModel.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) -> TimelinePollDetails {
|
||||
let answerOptions = poll.answerOptions.map { pollAnswerOption in
|
||||
TimelinePollAnswerOption(id: pollAnswerOption.id,
|
||||
text: pollAnswerOption.text,
|
||||
count: pollAnswerOption.count,
|
||||
winner: pollAnswerOption.isWinner,
|
||||
selected: pollAnswerOption.isCurrentUserSelection)
|
||||
}
|
||||
|
||||
return TimelinePollDetails(question: poll.text,
|
||||
answerOptions: answerOptions,
|
||||
closed: poll.isClosed,
|
||||
totalAnswerCount: poll.totalAnswerCount,
|
||||
type: pollKindToTimelinePollType(poll.kind),
|
||||
maxAllowedSelections: poll.maxAllowedSelections,
|
||||
hasBeenEdited: poll.hasBeenEdited)
|
||||
}
|
||||
|
||||
private func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType {
|
||||
let mapping = [PollKind.disclosed: TimelinePollType.disclosed,
|
||||
PollKind.undisclosed: TimelinePollType.undisclosed]
|
||||
|
||||
return mapping[kind] ?? .disclosed
|
||||
}
|
||||
}
|
||||
@@ -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 TimelinePollProvider {
|
||||
static let shared = TimelinePollProvider()
|
||||
|
||||
var session: MXSession?
|
||||
var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]()
|
||||
|
||||
private init() {
|
||||
|
||||
}
|
||||
|
||||
/// Create or retrieve the poll timeline coordinator for this event and return
|
||||
/// a view to be displayed in the timeline
|
||||
func buildTimelinePollViewForEvent(_ 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 = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event)
|
||||
guard let coordinator = try? TimelinePollCoordinator(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 timelinePollCoordinatorForEventIdentifier(_ eventIdentifier: String) -> TimelinePollCoordinator? {
|
||||
return coordinatorsForEventIdentifiers[eventIdentifier]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// 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 TimelinePollUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
continueAfterFailure = false
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testOpenDisclosedPoll() {
|
||||
app.goToScreenWithIdentifier(MockTimelinePollScreenState.openDisclosed.title)
|
||||
|
||||
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 testOpenUndisclosedPoll() {
|
||||
app.goToScreenWithIdentifier(MockTimelinePollScreenState.openUndisclosed.title)
|
||||
|
||||
XCTAssert(app.staticTexts["Question"].exists)
|
||||
XCTAssert(app.staticTexts["20 votes cast"].exists)
|
||||
|
||||
XCTAssert(!app.buttons["First, 10 votes"].exists)
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssertTrue((app.buttons["First"].value as! String).isEmpty)
|
||||
|
||||
XCTAssert(!app.buttons["Second, 5 votes"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssertTrue((app.buttons["Second"].value as! String).isEmpty)
|
||||
|
||||
XCTAssert(!app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
XCTAssertTrue((app.buttons["Third"].value as! String).isEmpty)
|
||||
|
||||
app.buttons["First"].tap()
|
||||
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
|
||||
app.buttons["Third"].tap()
|
||||
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
}
|
||||
|
||||
func testClosedDisclosedPoll() {
|
||||
app.goToScreenWithIdentifier(MockTimelinePollScreenState.closedDisclosed.title)
|
||||
checkClosedPoll()
|
||||
}
|
||||
|
||||
func testClosedUndisclosedPoll() {
|
||||
app.goToScreenWithIdentifier(MockTimelinePollScreenState.closedUndisclosed.title)
|
||||
checkClosedPoll()
|
||||
}
|
||||
|
||||
private func checkClosedPoll() {
|
||||
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,148 @@
|
||||
//
|
||||
// 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 TimelinePollViewModelTests: XCTestCase {
|
||||
var viewModel: TimelinePollViewModel!
|
||||
var context: TimelinePollViewModelType.Context!
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
|
||||
|
||||
let timelinePoll = TimelinePollDetails(question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: false,
|
||||
totalAnswerCount: 3,
|
||||
type: .disclosed,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false)
|
||||
|
||||
viewModel = TimelinePollViewModel(timelinePollDetails: 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,113 @@
|
||||
//
|
||||
// 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 TimelinePollViewModelCallback = ((TimelinePollViewModelResult) -> Void)
|
||||
|
||||
enum TimelinePollStateAction {
|
||||
case viewAction(TimelinePollViewAction, TimelinePollViewModelCallback?)
|
||||
case updateWithPoll(TimelinePollDetails)
|
||||
case showAnsweringFailure
|
||||
case showClosingFailure
|
||||
}
|
||||
|
||||
enum TimelinePollViewAction {
|
||||
case selectAnswerOptionWithIdentifier(String)
|
||||
}
|
||||
|
||||
enum TimelinePollViewModelResult {
|
||||
case selectedAnswerOptionsWithIdentifiers([String])
|
||||
}
|
||||
|
||||
enum TimelinePollType {
|
||||
case disclosed
|
||||
case undisclosed
|
||||
}
|
||||
|
||||
class TimelinePollAnswerOption: 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 TimelinePollDetails {
|
||||
var question: String
|
||||
var answerOptions: [TimelinePollAnswerOption]
|
||||
var closed: Bool
|
||||
var totalAnswerCount: UInt
|
||||
var type: TimelinePollType
|
||||
var maxAllowedSelections: UInt
|
||||
var hasBeenEdited: Bool = true
|
||||
|
||||
init(question: String, answerOptions: [TimelinePollAnswerOption],
|
||||
closed: Bool,
|
||||
totalAnswerCount: UInt,
|
||||
type: TimelinePollType,
|
||||
maxAllowedSelections: UInt,
|
||||
hasBeenEdited: Bool) {
|
||||
self.question = question
|
||||
self.answerOptions = answerOptions
|
||||
self.closed = closed
|
||||
self.totalAnswerCount = totalAnswerCount
|
||||
self.type = type
|
||||
self.maxAllowedSelections = maxAllowedSelections
|
||||
self.hasBeenEdited = hasBeenEdited
|
||||
}
|
||||
|
||||
var hasCurrentUserVoted: Bool {
|
||||
answerOptions.filter { $0.selected == true}.count > 0
|
||||
}
|
||||
|
||||
var shouldDiscloseResults: Bool {
|
||||
if closed {
|
||||
return totalAnswerCount > 0
|
||||
} else {
|
||||
return type == .disclosed && totalAnswerCount > 0 && hasCurrentUserVoted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelinePollViewState: BindableState {
|
||||
var poll: TimelinePollDetails
|
||||
var bindings: TimelinePollViewStateBindings
|
||||
}
|
||||
|
||||
struct TimelinePollViewStateBindings {
|
||||
var alertInfo: TimelinePollErrorAlertInfo?
|
||||
}
|
||||
|
||||
struct TimelinePollErrorAlertInfo: Identifiable {
|
||||
enum AlertType {
|
||||
case failedClosingPoll
|
||||
case failedSubmittingAnswer
|
||||
}
|
||||
|
||||
let id: AlertType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// 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 MockTimelinePollScreenState: MockScreenState, CaseIterable {
|
||||
case openDisclosed
|
||||
case closedDisclosed
|
||||
case openUndisclosed
|
||||
case closedUndisclosed
|
||||
|
||||
var screenType: Any.Type {
|
||||
TimelinePollDetails.self
|
||||
}
|
||||
|
||||
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 poll = TimelinePollDetails(question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: (self == .closedDisclosed || self == .closedUndisclosed ? true : false),
|
||||
totalAnswerCount: 20,
|
||||
type: (self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed),
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false)
|
||||
|
||||
let viewModel = TimelinePollViewModel(timelinePollDetails: poll)
|
||||
|
||||
return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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 TimelinePollViewModelType = StateStoreViewModel<TimelinePollViewState,
|
||||
TimelinePollStateAction,
|
||||
TimelinePollViewAction>
|
||||
@available(iOS 14, *)
|
||||
class TimelinePollViewModel: TimelinePollViewModelType {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: TimelinePollViewModelCallback?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(timelinePollDetails: TimelinePollDetails) {
|
||||
super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings()))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: TimelinePollViewAction) {
|
||||
switch viewAction {
|
||||
case .selectAnswerOptionWithIdentifier(_):
|
||||
dispatch(action: .viewAction(viewAction, callback))
|
||||
}
|
||||
}
|
||||
|
||||
override class func reducer(state: inout TimelinePollViewState, action: TimelinePollStateAction) {
|
||||
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.alertInfo = TimelinePollErrorAlertInfo(id: .failedSubmittingAnswer,
|
||||
title: VectorL10n.pollTimelineVoteNotRegisteredTitle,
|
||||
subtitle: VectorL10n.pollTimelineVoteNotRegisteredSubtitle)
|
||||
case .showClosingFailure:
|
||||
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedClosingPoll,
|
||||
title: VectorL10n.pollTimelineNotClosedTitle,
|
||||
subtitle: VectorL10n.pollTimelineNotClosedSubtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
static func updateSingleSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
|
||||
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 TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
|
||||
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: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
|
||||
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
|
||||
answerOption.selected ? answerOption.id : nil
|
||||
}
|
||||
|
||||
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// 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 TimelinePollAnswerOptionButton: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
let poll: TimelinePollDetails
|
||||
let answerOption: TimelinePollAnswerOption
|
||||
let action: () -> Void
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
let rect = RoundedRectangle(cornerRadius: 4.0)
|
||||
answerOptionLabel
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8.0)
|
||||
.padding(.top, 12.0)
|
||||
.padding(.bottom, 12.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 !poll.closed {
|
||||
Image(uiImage: answerOption.selected ? Asset.Images.pollCheckboxSelected.image : Asset.Images.pollCheckboxDefault.image)
|
||||
}
|
||||
|
||||
Text(answerOption.text)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
|
||||
if poll.closed && answerOption.winner {
|
||||
Spacer()
|
||||
Image(uiImage: Asset.Images.pollWinnerIcon.image)
|
||||
}
|
||||
}
|
||||
|
||||
if poll.type == .disclosed || poll.closed {
|
||||
HStack {
|
||||
ProgressView(value: Double(poll.shouldDiscloseResults ? answerOption.count : 0),
|
||||
total: Double(poll.totalAnswerCount))
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
|
||||
|
||||
if (poll.shouldDiscloseResults) {
|
||||
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var borderAccentColor: Color {
|
||||
guard !poll.closed else {
|
||||
return (answerOption.winner ? theme.colors.accent : theme.colors.quinaryContent)
|
||||
}
|
||||
|
||||
return answerOption.selected ? theme.colors.accent : theme.colors.quinaryContent
|
||||
}
|
||||
|
||||
var progressViewAccentColor: Color {
|
||||
guard !poll.closed else {
|
||||
return (answerOption.winner ? theme.colors.accent : theme.colors.quarterlyContent)
|
||||
}
|
||||
|
||||
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockTimelinePollScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
let pollTypes: [TimelinePollType] = [.disclosed, .undisclosed]
|
||||
|
||||
ForEach(pollTypes, id: \.self) { type in
|
||||
VStack {
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: false, type: type),
|
||||
answerOption: buildAnswerOption(selected: false),
|
||||
action: {})
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: false, type: type),
|
||||
answerOption: buildAnswerOption(selected: true),
|
||||
action: {})
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
|
||||
answerOption: buildAnswerOption(selected: false, winner: false),
|
||||
action: {})
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
|
||||
answerOption: buildAnswerOption(selected: false, winner: true),
|
||||
action: {})
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
|
||||
answerOption: buildAnswerOption(selected: true, winner: false),
|
||||
action: {})
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
|
||||
answerOption: buildAnswerOption(selected: true, winner: true),
|
||||
action: {})
|
||||
|
||||
let longText = "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."
|
||||
|
||||
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
|
||||
answerOption: buildAnswerOption(text: longText, selected: true, winner: true),
|
||||
action: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func buildPoll(closed: Bool, type: TimelinePollType) -> TimelinePollDetails {
|
||||
TimelinePollDetails(question: "",
|
||||
answerOptions: [],
|
||||
closed: closed,
|
||||
totalAnswerCount: 100,
|
||||
type: type,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false)
|
||||
}
|
||||
|
||||
static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption {
|
||||
TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// 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 TimelinePollView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: TimelinePollViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
let poll = viewModel.viewState.poll
|
||||
|
||||
VStack(alignment: .leading, spacing: 16.0) {
|
||||
|
||||
Text(poll.question)
|
||||
.font(theme.fonts.bodySB)
|
||||
.foregroundColor(theme.colors.primaryContent) +
|
||||
Text(editedText)
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
|
||||
VStack(spacing: 24.0) {
|
||||
ForEach(poll.answerOptions) { answerOption in
|
||||
TimelinePollAnswerOptionButton(poll: poll, answerOption: answerOption) {
|
||||
viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(poll.closed)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(totalVotesString)
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
}
|
||||
.padding([.horizontal, .top], 2.0)
|
||||
.padding([.bottom])
|
||||
.alert(item: $viewModel.alertInfo) { info in
|
||||
Alert(title: Text(info.title),
|
||||
message: Text(info.subtitle),
|
||||
dismissButton: .default(Text(VectorL10n.ok)))
|
||||
}
|
||||
}
|
||||
|
||||
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 || poll.type == .undisclosed ?
|
||||
VectorL10n.pollTimelineTotalOneVote :
|
||||
VectorL10n.pollTimelineTotalOneVoteNotVoted)
|
||||
default:
|
||||
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
|
||||
VectorL10n.pollTimelineTotalVotes(Int(poll.totalAnswerCount)) :
|
||||
VectorL10n.pollTimelineTotalVotesNotVoted(Int(poll.totalAnswerCount)))
|
||||
}
|
||||
}
|
||||
|
||||
private var editedText: String {
|
||||
viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct TimelinePollView_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockTimelinePollScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user