Merge branch 'develop' into ismail/5068_start_thread

This commit is contained in:
ismailgulek
2022-01-19 00:07:52 +03:00
72 changed files with 1053 additions and 673 deletions
@@ -1,20 +1,18 @@
// 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.
*/
//
// 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
@@ -22,6 +20,7 @@ import SwiftUI
struct PollEditFormCoordinatorParameters {
let room: MXRoom
let pollStartEvent: MXEvent?
}
final class PollEditFormCoordinator: Coordinator, Presentable {
@@ -40,7 +39,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
}
// MARK: Public
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
@@ -51,9 +50,20 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
init(parameters: PollEditFormCoordinatorParameters) {
self.parameters = parameters
let viewModel = PollEditFormViewModel()
let view = PollEditForm(viewModel: viewModel.context)
var viewModel: PollEditFormViewModel
if let startEvent = parameters.pollStartEvent,
let pollContent = MXEventContentPollStart(fromJSON: startEvent.content) {
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .editing,
pollDetails: EditFormPollDetails(type: Self.pollKindKeyToDetailsType(pollContent.kind),
question: pollContent.question,
answerOptions: pollContent.answerOptions.map { $0.text })))
} else {
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
}
let view = PollEditForm(viewModel: viewModel.context)
_pollEditFormViewModel = viewModel
pollEditFormHostingController = VectorHostingController(rootView: view)
}
@@ -70,16 +80,9 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
switch result {
case .cancel:
self.completion?()
case .create(let question, let answerOptions):
var options = [MXEventContentPollStartAnswerOption]()
for answerOption in answerOptions {
options.append(MXEventContentPollStartAnswerOption(uuid: UUID().uuidString, text: answerOption))
}
case .create(let details):
let pollStartContent = MXEventContentPollStart(question: question,
kind: kMXMessageContentKeyExtensiblePollKindDisclosed,
maxSelections: 1,
answerOptions: options)
let pollStartContent = self.buildPollContentWithDetails(details)
self.pollEditFormViewModel.dispatch(action: .startLoading)
@@ -92,15 +95,72 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
guard let self = self else { return }
MXLog.error("Failed creating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(error))
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedCreatingPoll))
}
case .update(let details):
guard let pollStartEvent = self.parameters.pollStartEvent else {
fatalError()
}
self.pollEditFormViewModel.dispatch(action: .startLoading)
guard let oldPollContent = MXEventContentPollStart(fromJSON: pollStartEvent.content) else {
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
return
}
let newPollContent = self.buildPollContentWithDetails(details)
self.parameters.room.sendPollUpdate(for: pollStartEvent,
oldContent: oldPollContent,
newContent: newPollContent, localEcho: nil) { [weak self] result in
guard let self = self else { return }
self.pollEditFormViewModel.dispatch(action: .stopLoading(nil))
self.completion?()
} failure: { [weak self] error in
guard let self = self else { return }
MXLog.error("Failed updating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
}
}
}
}
// MARK: - Private
// MARK: - Presentable
func toPresentable() -> UIViewController {
return pollEditFormHostingController
}
// MARK: - Private
private func buildPollContentWithDetails(_ details: EditFormPollDetails) -> MXEventContentPollStart {
var options = [MXEventContentPollStartAnswerOption]()
for answerOption in details.answerOptions {
options.append(MXEventContentPollStartAnswerOption(uuid: UUID().uuidString, text: answerOption))
}
return MXEventContentPollStart(question: details.question,
kind: Self.pollDetailsTypeToKindKey(details.type),
maxSelections: NSNumber(value: details.maxSelections),
answerOptions: options)
}
private static func pollDetailsTypeToKindKey(_ type: EditFormPollType) -> String {
let mapping = [EditFormPollType.disclosed : kMXMessageContentKeyExtensiblePollKindDisclosed,
EditFormPollType.undisclosed : kMXMessageContentKeyExtensiblePollKindUndisclosed]
return mapping[type] ?? kMXMessageContentKeyExtensiblePollKindDisclosed
}
private static func pollKindKeyToDetailsType(_ key: String) -> EditFormPollType {
let mapping = [kMXMessageContentKeyExtensiblePollKindDisclosed : EditFormPollType.disclosed,
kMXMessageContentKeyExtensiblePollKindUndisclosed : EditFormPollType.undisclosed]
return mapping[key] ?? EditFormPollType.disclosed
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,10 +17,31 @@
import Foundation
import SwiftUI
enum EditFormPollType {
case disclosed
case undisclosed
}
struct EditFormPollDetails {
let type: EditFormPollType
let question: String
let answerOptions: [String]
let maxSelections: UInt = 1
static var `default`: EditFormPollDetails {
EditFormPollDetails(type: .disclosed, question: "", answerOptions: ["", ""])
}
}
enum PollEditFormMode {
case creation
case editing
}
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
case startLoading
case stopLoading(Error?)
case stopLoading(PollEditFormErrorAlertInfo.AlertType?)
}
enum PollEditFormViewAction {
@@ -30,11 +49,13 @@ enum PollEditFormViewAction {
case deleteAnswerOption(PollEditFormAnswerOption)
case cancel
case create
case update
}
enum PollEditFormViewModelResult {
case cancel
case create(String, [String])
case create(EditFormPollDetails)
case update(EditFormPollDetails)
}
struct PollEditFormQuestion {
@@ -60,12 +81,14 @@ struct PollEditFormAnswerOption: Identifiable, Equatable {
}
struct PollEditFormViewState: BindableState {
var minAnswerOptionsCount: Int
var maxAnswerOptionsCount: Int
var mode: PollEditFormMode
var bindings: PollEditFormViewStateBindings
var confirmationButtonEnabled: Bool {
!bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
bindings.answerOptions.filter({ !$0.text.isEmpty }).count >= 2
bindings.answerOptions.filter({ !$0.text.isEmpty }).count >= minAnswerOptionsCount
}
var addAnswerOptionButtonEnabled: Bool {
@@ -78,6 +101,18 @@ struct PollEditFormViewState: BindableState {
struct PollEditFormViewStateBindings {
var question: PollEditFormQuestion
var answerOptions: [PollEditFormAnswerOption]
var type: EditFormPollType
var showsFailureAlert: Bool = false
var alertInfo: PollEditFormErrorAlertInfo?
}
struct PollEditFormErrorAlertInfo: Identifiable {
enum AlertType {
case failedCreatingPoll
case failedUpdatingPoll
}
let id: AlertType
let title: String
let subtitle: String
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -24,11 +22,11 @@ enum MockPollEditFormScreenState: MockScreenState, CaseIterable {
case standard
var screenType: Any.Type {
MockPollEditFormScreenState.self
PollEditForm.self
}
var screenView: ([Any], AnyView) {
let viewModel = PollEditFormViewModel()
let viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
return ([viewModel], AnyView(PollEditForm(viewModel: viewModel.context)))
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,6 +17,11 @@
import SwiftUI
import Combine
struct PollEditFormViewModelParameters {
let mode: PollEditFormMode
let pollDetails: EditFormPollDetails
}
@available(iOS 14, *)
typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState,
PollEditFormStateAction,
@@ -27,6 +30,7 @@ typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState
class PollEditFormViewModel: PollEditFormViewModelType {
private struct Constants {
static let minAnswerOptionsCount = 2
static let maxAnswerOptionsCount = 20
static let maxQuestionLength = 340
static let maxAnswerOptionLength = 340
@@ -42,20 +46,19 @@ class PollEditFormViewModel: PollEditFormViewModelType {
// MARK: - Setup
init() {
super.init(initialViewState: Self.defaultState())
}
private static func defaultState() -> PollEditFormViewState {
return PollEditFormViewState(
init(parameters: PollEditFormViewModelParameters) {
let state = PollEditFormViewState(
minAnswerOptionsCount: Constants.minAnswerOptionsCount,
maxAnswerOptionsCount: Constants.maxAnswerOptionsCount,
mode: parameters.mode,
bindings: PollEditFormViewStateBindings(
question: PollEditFormQuestion(text: "", maxLength: Constants.maxQuestionLength),
answerOptions: [PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength),
PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength)
]
question: PollEditFormQuestion(text: parameters.pollDetails.question, maxLength: Constants.maxQuestionLength),
answerOptions: parameters.pollDetails.answerOptions.map { PollEditFormAnswerOption(text: $0, maxLength: Constants.maxAnswerOptionLength) },
type: parameters.pollDetails.type
)
)
super.init(initialViewState: state)
}
// MARK: - Public
@@ -65,11 +68,9 @@ class PollEditFormViewModel: PollEditFormViewModelType {
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
})))
completion?(.create(buildPollDetails()))
case .update:
completion?(.update(buildPollDetails()))
default:
dispatch(action: .viewAction(viewAction))
}
@@ -92,10 +93,30 @@ class PollEditFormViewModel: PollEditFormViewModelType {
case .stopLoading(let error):
state.showLoadingIndicator = false
if error != nil {
state.bindings.showsFailureAlert = true
switch error {
case .failedCreatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedCreatingPoll,
title: VectorL10n.pollEditFormPostFailureTitle,
subtitle: VectorL10n.pollEditFormPostFailureSubtitle)
case .failedUpdatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedUpdatingPoll,
title: VectorL10n.pollEditFormUpdateFailureTitle,
subtitle: VectorL10n.pollEditFormUpdateFailureSubtitle)
case .none:
break
}
break
}
}
// MARK: - Private
private func buildPollDetails() -> EditFormPollDetails {
return EditFormPollDetails(type: state.bindings.type,
question: state.bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines),
answerOptions: state.bindings.answerOptions.compactMap({ answerOption in
let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}))
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -28,10 +26,10 @@ class PollEditFormViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
viewModel = PollEditFormViewModel()
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
context = viewModel.context
}
func testInitialState() {
XCTAssertTrue(context.question.text.isEmpty)
XCTAssertFalse(context.viewState.confirmationButtonEnabled)
@@ -100,14 +98,14 @@ class PollEditFormViewModelTests: XCTestCase {
let thirdAnswer = " "
viewModel.completion = { result in
if case PollEditFormViewModelResult.create(let resultQuestion, let resultAnswerOptions) = result {
XCTAssertEqual(question.trimmingCharacters(in: .whitespacesAndNewlines), resultQuestion)
if case PollEditFormViewModelResult.create(let result) = result {
XCTAssertEqual(question.trimmingCharacters(in: .whitespacesAndNewlines), result.question)
// The last answer option should be automatically dropped as it's empty
XCTAssertEqual(resultAnswerOptions.count, 2)
XCTAssertEqual(result.answerOptions.count, 2)
XCTAssertEqual(resultAnswerOptions[0], firstAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(resultAnswerOptions[1], secondAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(result.answerOptions[0], firstAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(result.answerOptions[1], secondAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -37,6 +35,9 @@ struct PollEditForm: View {
ScrollView {
VStack(alignment: .leading, spacing: 32.0) {
// Intentionally disabled until platform parity.
// PollEditFormTypePicker(selectedType: $viewModel.type)
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormPollQuestionOrTopic)
.font(theme.fonts.title3SB)
@@ -58,7 +59,7 @@ struct PollEditForm: View {
ForEach(0..<viewModel.answerOptions.count, id: \.self) { index in
SafeBindingCollectionEnumerator($viewModel.answerOptions, index: index) { binding in
AnswerOptionGroup(text: binding.text, index: index) {
PollEditFormAnswerOptionView(text: binding.text, index: index) {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.send(viewAction: .deleteAnswerOption(viewModel.answerOptions[index]))
}
@@ -76,17 +77,20 @@ struct PollEditForm: View {
Spacer()
Button(VectorL10n.pollEditFormCreatePoll) {
viewModel.send(viewAction: .create)
if viewModel.viewState.mode == .creation {
Button(VectorL10n.pollEditFormCreatePoll) {
viewModel.send(viewAction: .create)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.padding()
.padding(.vertical, 24.0)
.padding(.horizontal, 16.0)
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.alert(isPresented: $viewModel.showsFailureAlert) {
Alert(title: Text(VectorL10n.pollEditFormPostFailureTitle),
message: Text(VectorL10n.pollEditFormPostFailureSubtitle),
.alert(item: $viewModel.alertInfo) { info in
Alert(title: Text(info.title),
message: Text(info.subtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
.frame(minHeight: proxy.size.height) // Make the VStack fill the ScrollView's parent
@@ -101,6 +105,15 @@ struct PollEditForm: View {
.font(.headline)
.foregroundColor(theme.colors.primaryContent)
}
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.viewState.mode == .editing {
Button(VectorL10n.save, action: {
viewModel.send(viewAction: .update)
})
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
@@ -111,40 +124,6 @@ struct PollEditForm: View {
}
}
@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, *)
@@ -0,0 +1,63 @@
//
// 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 PollEditFormAnswerOptionView: 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(action: onDelete) {
Image(uiImage:Asset.Images.pollDeleteOptionIcon.image)
}
.accessibilityIdentifier("Delete answer option")
}
}
}
}
@available(iOS 14.0, *)
struct PollEditFormAnswerOptionView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 32.0) {
PollEditFormAnswerOptionView(text: Binding.constant(""), index: 0) {
}
PollEditFormAnswerOptionView(text: Binding.constant("Test"), index: 5) {
}
}
}
}
@@ -0,0 +1,98 @@
//
// 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 PollEditFormTypePicker: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@Binding var selectedType: EditFormPollType
var body: some View {
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormPollType)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
PollEditFormTypeButton(type: .disclosed, selectedType: $selectedType)
PollEditFormTypeButton(type: .undisclosed, selectedType: $selectedType)
}
}
}
@available(iOS 14.0, *)
private struct PollEditFormTypeButton: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let type: EditFormPollType
@Binding var selectedType: EditFormPollType
var body: some View {
Button {
selectedType = type
} label: {
HStack(alignment: .top, spacing: 8.0) {
Image(uiImage: selectionImage)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
Text(description)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
}
}
}
}
private var title: String {
switch type {
case .disclosed:
return VectorL10n.pollEditFormPollTypeOpen
case .undisclosed:
return VectorL10n.pollEditFormPollTypeClosed
}
}
private var description: String {
switch type {
case .disclosed:
return VectorL10n.pollEditFormPollTypeOpenDescription
case .undisclosed:
return VectorL10n.pollEditFormPollTypeClosedDescription
}
}
private var selectionImage: UIImage {
if type == selectedType {
return Asset.Images.pollTypeCheckboxSelected.image
} else {
return Asset.Images.pollTypeCheckboxDefault.image
}
}
}
@available(iOS 14.0, *)
struct PollEditFormTypePicker_Previews: PreviewProvider {
static var previews: some View {
VStack {
PollEditFormTypePicker(selectedType: Binding.constant(.disclosed))
PollEditFormTypePicker(selectedType: Binding.constant(.undisclosed))
}
}
}