vector-im/element-ios/issues/5114 - Poll creation screen

- added input toolbar poll creation action.
- reordered input toolbar actions as per designs.
- added multiline text field and extracted common components.
This commit is contained in:
Stefan Ceriu
2021-11-01 16:52:00 +02:00
committed by Stefan Ceriu
parent 01188f593e
commit ba9c40cf2d
50 changed files with 1231 additions and 42 deletions

View File

@@ -0,0 +1,81 @@
// 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 Foundation
import UIKit
import SwiftUI
struct PollEditFormCoordinatorParameters {
let navigationRouter: NavigationRouterType?
}
final class PollEditFormCoordinator: Coordinator {
// MARK: - Properties
// MARK: Private
private let parameters: PollEditFormCoordinatorParameters
private let pollEditFormHostingController: UIViewController
private var _pollEditFormViewModel: Any? = nil
@available(iOS 14.0, *)
fileprivate var pollEditFormViewModel: PollEditFormViewModel {
return _pollEditFormViewModel as! PollEditFormViewModel
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: PollEditFormCoordinatorParameters) {
self.parameters = parameters
let viewModel = PollEditFormViewModel()
let view = PollEditForm(viewModel: viewModel.context)
_pollEditFormViewModel = viewModel
pollEditFormHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
guard #available(iOS 14.0, *) else {
MXLog.debug("[PollEditFormCoordinator] start: Invalid iOS version, returning.")
return
}
MXLog.debug("[PollEditFormCoordinator] did start.")
parameters.navigationRouter?.present(pollEditFormHostingController, animated: true)
pollEditFormViewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel:
self.parameters.navigationRouter?.dismissModule(animated: true, completion: nil)
case .create(_, _):
break
}
}
}
}

View File

@@ -0,0 +1,77 @@
// 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 Foundation
import SwiftUI
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
}
enum PollEditFormViewAction {
case addAnswerOption
case deleteAnswerOption(PollEditFormAnswerOption)
case cancel
case create
}
enum PollEditFormViewModelResult {
case cancel
case create(String, [String])
}
struct PollEditFormQuestion {
var text: String {
didSet {
text = String(text.prefix(maxLength))
}
}
let maxLength: Int
}
struct PollEditFormAnswerOption: Identifiable, Equatable {
let id = UUID()
var text: String {
didSet {
text = String(text.prefix(maxLength))
}
}
let maxLength: Int
}
struct PollEditFormViewState: BindableState {
let maxAnswerOptionsCount: Int
var bindings: PollEditFormViewStateBindings
var confirmationButtonEnabled: Bool {
!bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
bindings.answerOptions.filter({ !$0.text.isEmpty }).count >= 2
}
var addAnswerOptionButtonEnabled: Bool {
bindings.answerOptions.count < maxAnswerOptionsCount
}
}
struct PollEditFormViewStateBindings {
var question: PollEditFormQuestion
var answerOptions: [PollEditFormAnswerOption]
}

View File

@@ -0,0 +1,34 @@
// 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 MockPollEditFormScreenState: MockScreenState, CaseIterable {
case standard
var screenType: Any.Type {
MockPollEditFormScreenState.self
}
var screenView: ([Any], AnyView) {
let viewModel = PollEditFormViewModel()
return ([viewModel], AnyView(PollEditForm(viewModel: viewModel.context)))
}
}

View File

@@ -0,0 +1,91 @@
// 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
import Combine
@available(iOS 14, *)
typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState,
PollEditFormStateAction,
PollEditFormViewAction >
@available(iOS 14, *)
class PollEditFormViewModel: PollEditFormViewModelType {
private struct Constants {
static let maxAnswerOptionsCount = 20
static let maxQuestionLength = 200
static let maxAnswerOptionLength = 200
}
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((PollEditFormViewModelResult) -> Void)?
// MARK: - Setup
init() {
super.init(initialViewState: Self.defaultState())
}
private static func defaultState() -> PollEditFormViewState {
return PollEditFormViewState(
maxAnswerOptionsCount: Constants.maxAnswerOptionsCount,
bindings: PollEditFormViewStateBindings(
question: PollEditFormQuestion(text: "", maxLength: Constants.maxQuestionLength),
answerOptions: [PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength),
PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength)
]
)
)
}
// MARK: - Public
override func process(viewAction: PollEditFormViewAction) {
switch viewAction {
case .cancel:
completion?(.cancel)
case .create:
completion?(.create(state.bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines),
state.bindings.answerOptions.compactMap({ answerOption in
let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
})))
default:
dispatch(action: .viewAction(viewAction))
}
}
override class func reducer(state: inout PollEditFormViewState, action: PollEditFormStateAction) {
switch action {
case .viewAction(let viewAction):
switch viewAction {
case .deleteAnswerOption(let answerOption):
state.bindings.answerOptions.removeAll { $0 == answerOption }
case .addAnswerOption:
state.bindings.answerOptions.append(PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength))
default:
break
}
}
}
}

View File

@@ -0,0 +1,82 @@
// 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 XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class PollEditFormUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
app.buttons[MockPollEditFormScreenState.screenStateKeys.first!].tap()
}
func testInitialStateComponents() {
XCTAssert(app.scrollViews.firstMatch.exists)
XCTAssert(app.staticTexts["Create poll"].exists)
XCTAssert(app.staticTexts["Poll question or topic"].exists)
XCTAssert(app.staticTexts["Question or topic"].exists)
XCTAssert(app.staticTexts["Create options"].exists)
XCTAssert(app.textViews.count == 1)
XCTAssert(app.textFields.count == 2)
XCTAssert(app.staticTexts["Option 1"].exists)
XCTAssert(app.staticTexts["Option 2"].exists)
let cancelButton = app.buttons["Cancel"]
XCTAssert(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
let addOptionButton = app.buttons["Add option"]
XCTAssert(addOptionButton.exists)
XCTAssertTrue(addOptionButton.isEnabled)
let createPollButton = app.buttons["Create poll"]
XCTAssert(createPollButton.exists)
XCTAssertFalse(createPollButton.isEnabled)
}
func testRemoveAddAnswerOptions() {
let deleteAnswerOptionButton = app.buttons["Delete answer option"].firstMatch
XCTAssert(deleteAnswerOptionButton.waitForExistence(timeout: 2.0))
deleteAnswerOptionButton.tap()
XCTAssert(deleteAnswerOptionButton.waitForExistence(timeout: 2.0))
deleteAnswerOptionButton.tap()
let addOptionButton = app.buttons["Add option"]
XCTAssert(addOptionButton.waitForExistence(timeout: 2.0))
XCTAssertTrue(addOptionButton.isEnabled)
for i in 1...3 {
addOptionButton.tap()
XCTAssert(app.staticTexts["Option \(i)"].waitForExistence(timeout: 2.0))
}
}
}

View File

@@ -0,0 +1,123 @@
// 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 XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class PollEditFormViewModelTests: XCTestCase {
var viewModel: PollEditFormViewModel!
var context: PollEditFormViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
viewModel = PollEditFormViewModel()
context = viewModel.context
}
func testInitialState() {
XCTAssertTrue(context.question.text.isEmpty)
XCTAssertFalse(context.viewState.confirmationButtonEnabled)
XCTAssertTrue(context.viewState.addAnswerOptionButtonEnabled)
XCTAssertEqual(context.answerOptions.count, 2)
for answerOption in context.answerOptions {
XCTAssertTrue(answerOption.text.isEmpty)
}
}
func testDeleteAllAnswerOptions() {
while !context.answerOptions.isEmpty {
context.send(viewAction: .deleteAnswerOption(context.answerOptions.first!))
}
XCTAssertEqual(context.answerOptions.count, 0)
XCTAssertFalse(context.viewState.confirmationButtonEnabled)
XCTAssertTrue(context.viewState.addAnswerOptionButtonEnabled)
}
func testAddRemoveAnswerOption() {
context.send(viewAction: .addAnswerOption)
XCTAssertEqual(context.answerOptions.count, 3)
context.send(viewAction: .deleteAnswerOption(context.answerOptions.first!))
XCTAssertEqual(context.answerOptions.count, 2)
}
func testCreateEnabled() {
context.question.text = "Some question"
context.answerOptions[0].text = "First answer"
context.answerOptions[1].text = "Second answer"
XCTAssertTrue(context.viewState.confirmationButtonEnabled)
}
func testReachedMaxAnswerOptions() {
for _ in 0...context.viewState.maxAnswerOptionsCount {
context.send(viewAction: .addAnswerOption)
}
XCTAssertFalse(context.viewState.addAnswerOptionButtonEnabled)
}
func testQuestionMaxLength() {
let question = String(repeating: "S", count: context.question.maxLength + 100)
context.question.text = question
XCTAssertEqual(context.question.text.count, context.question.maxLength)
}
func testAnswerOptionMaxLength() {
let answerOption = String(repeating: "S", count: context.answerOptions[0].maxLength + 100)
context.answerOptions[0].text = answerOption
XCTAssertEqual(context.answerOptions[0].text.count, context.answerOptions[0].maxLength)
}
func testFormCompletion() {
let question = "Some question "
let firstAnswer = "First answer "
let secondAnswer = "Second answer "
let thirdAnswer = " "
viewModel.completion = { result in
if case PollEditFormViewModelResult.create(let resultQuestion, let resultAnswerOptions) = result {
XCTAssertEqual(question.trimmingCharacters(in: .whitespacesAndNewlines), resultQuestion)
// The last answer option should be automatically dropped as it's empty
XCTAssertEqual(resultAnswerOptions.count, 2)
XCTAssertEqual(resultAnswerOptions[0], firstAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(resultAnswerOptions[1], secondAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
context.question.text = question
context.answerOptions[0].text = firstAnswer
context.answerOptions[1].text = secondAnswer
context.send(viewAction: .addAnswerOption)
context.answerOptions[2].text = thirdAnswer
context.send(viewAction: .create)
}
}

View File

@@ -0,0 +1,146 @@
// 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 PollEditForm: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: PollEditFormViewModel.Context
var body: some View {
NavigationView {
GeometryReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 32.0) {
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormPollQuestionOrTopic)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
VStack(alignment: .leading, spacing: 8.0) {
Text(VectorL10n.pollEditFormQuestionOrTopic)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.primaryContent)
MultilineTextField(VectorL10n.pollEditFormInputPlaceholder, text: $viewModel.question.text)
}
}
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormCreateOptions)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
ForEach(0..<viewModel.answerOptions.count, id: \.self) { index in
SafeBindingCollectionEnumerator($viewModel.answerOptions, index: index) { binding in
AnswerOptionGroup(text: binding.text, index: index) {
viewModel.send(viewAction: .deleteAnswerOption(viewModel.answerOptions[index]))
}
}
}
}
Button(VectorL10n.pollEditFormAddOption) {
viewModel.send(viewAction: .addAnswerOption)
}
.disabled(!viewModel.viewState.addAnswerOptionButtonEnabled)
Spacer()
Button(VectorL10n.pollEditFormCreatePoll) {
viewModel.send(viewAction: .create)
}
.buttonStyle(PrimaryActionButtonStyle(enabled: viewModel.viewState.confirmationButtonEnabled))
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.animation(.easeInOut(duration: 0.2))
.padding()
.frame(minHeight: proxy.size.height) // Make the VStack fill the ScrollView's parent
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(VectorL10n.cancel, action: {
viewModel.send(viewAction: .cancel)
})
}
ToolbarItem(placement: .principal) {
Text(VectorL10n.pollEditFormCreatePoll)
.font(.headline)
.foregroundColor(theme.colors.primaryContent)
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
}
.accentColor(theme.colors.accent)
}
}
@available(iOS 14.0, *)
private struct AnswerOptionGroup: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var focused = false
@Binding var text: String
let index: Int
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
Text(VectorL10n.pollEditFormOptionNumber(index + 1))
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.primaryContent)
HStack(spacing: 16.0) {
TextField(VectorL10n.pollEditFormInputPlaceholder, text: $text, onEditingChanged: { edit in
self.focused = edit
})
.textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: focused))
Button {
onDelete()
} label: {
Image(uiImage:Asset.Images.pollDeleteOptionIcon.image)
}
.accessibilityIdentifier("Delete answer option")
}
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct PollEditForm_Previews: PreviewProvider {
static let stateRenderer = MockPollEditFormScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}