Merge branch gil/5230_SP2-Adding_Rooms_to_Spaces into gil/5231_SP3-1_Update_room_settings_for_Spaces

This commit is contained in:
Gil Eluard
2022-02-05 21:43:29 +01:00
548 changed files with 20963 additions and 3538 deletions
@@ -17,7 +17,6 @@
import Foundation
import UIKit
import SwiftUI
import Keys
struct LocationSharingCoordinatorParameters {
let roomDataSource: MXKRoomDataSource
@@ -34,12 +33,7 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
private let parameters: LocationSharingCoordinatorParameters
private let locationSharingHostingController: UIViewController
private var _locationSharingViewModel: Any? = nil
@available(iOS 14.0, *)
fileprivate var locationSharingViewModel: LocationSharingViewModel {
return _locationSharingViewModel as! LocationSharingViewModel
}
private var locationSharingViewModel: LocationSharingViewModelProtocol
// MARK: Public
@@ -53,13 +47,13 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
init(parameters: LocationSharingCoordinatorParameters) {
self.parameters = parameters
let viewModel = LocationSharingViewModel(tileServerMapURL: BuildSettings.tileServerMapURL,
let viewModel = LocationSharingViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL,
avatarData: parameters.avatarData,
location: parameters.location)
let view = LocationSharingView(context: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
_locationSharingViewModel = viewModel
locationSharingViewModel = viewModel
locationSharingHostingController = VectorHostingController(rootView: view)
}
@@ -78,43 +72,37 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
self.completion?()
case .share(let latitude, let longitude):
if let location = self.parameters.location {
self.showActivityControllerForLocation(location)
self.locationSharingHostingController.present(Self.shareLocationActivityController(location), animated: true)
return
}
self.locationSharingViewModel.dispatch(action: .startLoading)
self.locationSharingViewModel.startLoading()
self.parameters.roomDataSource.sendLocation(withLatitude: latitude,
longitude: longitude,
description: nil) { [weak self] _ in
self.parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil) { [weak self] _ in
guard let self = self else { return }
self.locationSharingViewModel.dispatch(action: .stopLoading(nil))
self.locationSharingViewModel.stopLoading()
self.completion?()
} failure: { [weak self] error in
guard let self = self else { return }
MXLog.error("[LocationSharingCoordinator] Failed sharing location with error: \(String(describing: error))")
self.locationSharingViewModel.dispatch(action: .stopLoading(error))
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
}
}
}
}
static func shareLocationActivityController(_ location: CLLocationCoordinate2D) -> UIActivityViewController {
return UIActivityViewController(activityItems: [ShareToMapsAppActivity.urlForMapsAppType(.apple, location: location)],
applicationActivities: [ShareToMapsAppActivity(type: .apple, location: location),
ShareToMapsAppActivity(type: .google, location: location)])
}
// MARK: - Presentable
func toPresentable() -> UIViewController {
return locationSharingHostingController
}
// MARK: - Private
private func showActivityControllerForLocation(_ location: CLLocationCoordinate2D) {
let vc = UIActivityViewController(activityItems: [ShareToMapsAppActivity.urlForMapsAppType(.apple, location: location)],
applicationActivities: [ShareToMapsAppActivity(type: .apple, location: location),
ShareToMapsAppActivity(type: .google, location: location)])
locationSharingHostingController.present(vc, animated: true)
}
}
@@ -26,8 +26,8 @@ class ShareToMapsAppActivity: UIActivity {
case google
}
let type: MapsAppType
let location: CLLocationCoordinate2D
private let type: MapsAppType
private let location: CLLocationCoordinate2D
private override init() {
fatalError()
@@ -19,6 +19,16 @@ import SwiftUI
import Combine
import CoreLocation
enum LocationSharingViewAction {
case cancel
case share
}
enum LocationSharingViewModelResult {
case cancel
case share(latitude: Double, longitude: Double)
}
enum LocationSharingViewError {
case failedLoadingMap
case failedLocatingUser
@@ -26,27 +36,9 @@ enum LocationSharingViewError {
case failedSharingLocation
}
enum LocationSharingStateAction {
case error(LocationSharingViewError, LocationSharingViewModelCallback?)
case startLoading
case stopLoading(Error?)
}
enum LocationSharingViewAction {
case cancel
case share
}
typealias LocationSharingViewModelCallback = ((LocationSharingViewModelResult) -> Void)
enum LocationSharingViewModelResult {
case cancel
case share(latitude: Double, longitude: Double)
}
@available(iOS 14, *)
struct LocationSharingViewState: BindableState {
let tileServerMapURL: URL
let mapStyleURL: URL
let avatarData: AvatarInputProtocol
let location: CLLocationCoordinate2D?
@@ -66,11 +58,11 @@ struct LocationSharingViewState: BindableState {
}
struct LocationSharingViewStateBindings {
var alertInfo: ErrorAlertInfo?
var alertInfo: LocationSharingErrorAlertInfo?
var userLocation: CLLocationCoordinate2D?
}
struct ErrorAlertInfo: Identifiable {
struct LocationSharingErrorAlertInfo: Identifiable {
enum AlertType {
case mapLoadingError
case userLocatingError
@@ -80,6 +72,7 @@ struct ErrorAlertInfo: Identifiable {
let id: AlertType
let title: String
var subtitle: String? = nil
let primaryButton: (title: String, action: (() -> Void)?)
let secondaryButton: (title: String, action: (() -> Void)?)?
var secondaryButton: (title: String, action: (() -> Void)?)? = nil
}
@@ -16,7 +16,6 @@
import Foundation
import SwiftUI
import Keys
import CoreLocation
@available(iOS 14.0, *)
@@ -25,7 +24,7 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
case displayExistingLocation
var screenType: Any.Type {
MockLocationSharingScreenState.self
LocationSharingView.self
}
var screenView: ([Any], AnyView) {
@@ -35,8 +34,8 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
location = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)
}
let mapURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=" + RiotKeys().mapTilerAPIKey)!
let viewModel = LocationSharingViewModel(tileServerMapURL: mapURL,
let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")!
let viewModel = LocationSharingViewModel(mapStyleURL: mapStyleURL,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Alice"),
location: location)
return ([viewModel],
@@ -19,11 +19,11 @@ import Combine
import CoreLocation
@available(iOS 14, *)
typealias LocationSharingViewModelType = StateStoreViewModel< LocationSharingViewState,
LocationSharingStateAction,
LocationSharingViewAction >
typealias LocationSharingViewModelType = StateStoreViewModel<LocationSharingViewState,
Never,
LocationSharingViewAction>
@available(iOS 14, *)
class LocationSharingViewModel: LocationSharingViewModelType {
class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingViewModelProtocol {
// MARK: - Properties
@@ -35,13 +35,13 @@ class LocationSharingViewModel: LocationSharingViewModelType {
// MARK: - Setup
init(tileServerMapURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil) {
let viewState = LocationSharingViewState(tileServerMapURL: tileServerMapURL, avatarData: avatarData, location: location)
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil) {
let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL, avatarData: avatarData, location: location)
super.init(initialViewState: viewState)
state.errorSubject.sink { [weak self] error in
guard let self = self else { return }
self.dispatch(action: .error(error, self.completion))
self.processError(error)
}.store(in: &cancellables)
}
@@ -58,7 +58,7 @@ class LocationSharingViewModel: LocationSharingViewModelType {
}
guard let location = state.bindings.userLocation else {
dispatch(action: .error(.failedLocatingUser, completion))
processError(.failedLocatingUser)
return
}
@@ -66,45 +66,54 @@ class LocationSharingViewModel: LocationSharingViewModelType {
}
}
override class func reducer(state: inout LocationSharingViewState, action: LocationSharingStateAction) {
switch action {
case .error(let error, let completion):
switch error {
case .failedLoadingMap:
state.bindings.alertInfo = ErrorAlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName) ,
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .failedLocatingUser:
state.bindings.alertInfo = ErrorAlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .invalidLocationAuthorization:
state.bindings.alertInfo = ErrorAlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, { completion?(.cancel) }),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
default:
break
}
case .startLoading:
state.showLoadingIndicator = true
case .stopLoading(let error):
state.showLoadingIndicator = false
if error != nil {
state.bindings.alertInfo = ErrorAlertInfo(id: .locationSharingError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, nil),
secondaryButton: nil)
}
// MARK: - LocationSharingViewModelProtocol
public func startLoading() {
state.showLoadingIndicator = true
}
func stopLoading(error: LocationSharingErrorAlertInfo.AlertType?) {
state.showLoadingIndicator = false
if let error = error {
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: error,
title: VectorL10n.locationSharingPostFailureTitle,
subtitle: VectorL10n.locationSharingPostFailureSubtitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, nil))
}
}
// MARK: - Private
private func processError(_ error: LocationSharingViewError) {
guard state.bindings.alertInfo == nil else {
return
}
let primaryButtonCompletion = { [weak self] () -> Void in
self?.completion?(.cancel)
}
switch error {
case .failedLoadingMap:
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
case .failedLocatingUser:
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
case .invalidLocationAuthorization:
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, primaryButtonCompletion),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
default:
break
}
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -18,7 +16,15 @@
import Foundation
@available(iOS 14.0, *)
enum UserSuggestionStateAction {
case updateWithItems([UserSuggestionItemProtocol])
protocol LocationSharingViewModelProtocol {
var completion: ((LocationSharingViewModelResult) -> Void)? { get set }
func startLoading()
func stopLoading(error: LocationSharingErrorAlertInfo.AlertType?)
}
extension LocationSharingViewModelProtocol {
func stopLoading() {
stopLoading(error: nil)
}
}
@@ -41,7 +41,7 @@ class LocationSharingUITests: XCTestCase {
goToScreenWithIdentifier(MockLocationSharingScreenState.displayExistingLocation.title)
XCTAssertTrue(app.buttons["Cancel"].exists)
XCTAssertTrue(app.buttons["location share icon"].exists)
XCTAssertTrue(app.buttons["LocationSharingView.shareButton"].exists)
XCTAssertTrue(app.otherElements["Map"].exists)
}
@@ -32,7 +32,7 @@ class LocationSharingViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.context.viewState.shareButtonVisible)
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
XCTAssertNotNil(viewModel.context.viewState.tileServerMapURL)
XCTAssertNotNil(viewModel.context.viewState.mapStyleURL)
XCTAssertNotNil(viewModel.context.viewState.avatarData)
XCTAssertNil(viewModel.context.viewState.location)
@@ -100,12 +100,12 @@ class LocationSharingViewModelTests: XCTestCase {
func testLoading() {
let viewModel = buildViewModel(withLocation: false)
viewModel.dispatch(action: .startLoading)
viewModel.startLoading()
XCTAssertFalse(viewModel.context.viewState.shareButtonEnabled)
XCTAssertTrue(viewModel.context.viewState.showLoadingIndicator)
viewModel.dispatch(action: .stopLoading(nil))
viewModel.stopLoading()
XCTAssertTrue(viewModel.context.viewState.shareButtonEnabled)
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
@@ -121,8 +121,8 @@ class LocationSharingViewModelTests: XCTestCase {
}
private func buildViewModel(withLocation: Bool) -> LocationSharingViewModel {
LocationSharingViewModel(tileServerMapURL: URL(string: "http://empty.com")!,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
location: (withLocation ? CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) : nil))
LocationSharingViewModel(mapStyleURL: URL(string: "http://empty.com")!,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
location: (withLocation ? CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) : nil))
}
}
@@ -68,7 +68,7 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
private let avatarData: AvatarInputProtocol
private let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
@Binding var userLocation: CLLocationCoordinate2D?
@Binding private var userLocation: CLLocationCoordinate2D?
init(avatarData: AvatarInputProtocol,
errorSubject: PassthroughSubject<LocationSharingViewError, Never>,
@@ -89,6 +89,10 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
}
func mapView(_ mapView: MGLMapView, didFailToLocateUserWithError error: Error) {
guard mapView.showsUserLocation else {
return
}
errorSubject.send(.failedLocatingUser)
}
@@ -97,11 +101,15 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
}
func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) {
guard mapView.showsUserLocation else {
return
}
switch manager.authorizationStatus {
case .restricted:
fallthrough
case .denied:
errorSubject.send(.failedLocatingUser)
errorSubject.send(.invalidLocationAuthorization)
default:
break
}
@@ -25,16 +25,20 @@ struct LocationSharingUserMarkerView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var frame: CGRect = .zero
// MARK: Public
let avatarData: AvatarInputProtocol
var body: some View {
ZStack(alignment: .center) {
ZStack {
Image(uiImage: Asset.Images.locationUserMarker.image)
AvatarImage(avatarData: avatarData, size: .large)
.offset(.init(width: 0.0, height: -1.5))
.offset(y: -1.5)
}
.background(ViewFrameReader(frame: $frame))
.padding(.bottom, frame.height)
.accentColor(theme.colors.accent)
}
}
@@ -32,7 +32,7 @@ struct LocationSharingView: View {
var body: some View {
NavigationView {
LocationSharingMapView(tileServerMapURL: context.viewState.tileServerMapURL,
LocationSharingMapView(tileServerMapURL: context.viewState.mapStyleURL,
avatarData: context.viewState.avatarData,
location: context.viewState.location,
errorSubject: context.viewState.errorSubject,
@@ -54,6 +54,7 @@ struct LocationSharingView: View {
context.send(viewAction: .share)
} label: {
Image(uiImage: Asset.Images.locationShareIcon.image)
.accessibilityIdentifier("LocationSharingView.shareButton")
}
.disabled(!context.viewState.shareButtonEnabled)
} else {
@@ -69,6 +70,7 @@ struct LocationSharingView: View {
.alert(item: $context.alertInfo) { info in
if let secondaryButton = info.secondaryButton {
return Alert(title: Text(info.title),
message: subtitleTextForAlertInfo(info),
primaryButton: .default(Text(info.primaryButton.title)) {
info.primaryButton.action?()
},
@@ -77,6 +79,7 @@ struct LocationSharingView: View {
})
} else {
return Alert(title: Text(info.title),
message: subtitleTextForAlertInfo(info),
dismissButton: .default(Text(info.primaryButton.title)) {
info.primaryButton.action?()
})
@@ -85,6 +88,10 @@ struct LocationSharingView: View {
}
.accentColor(theme.colors.accent)
.activityIndicator(show: context.viewState.showLoadingIndicator)
.navigationViewStyle(StackNavigationViewStyle())
.introspectNavigationController { navigationController in
ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar)
}
}
@ViewBuilder
@@ -93,6 +100,14 @@ struct LocationSharingView: View {
ActivityIndicator()
}
}
private func subtitleTextForAlertInfo(_ alertInfo: LocationSharingErrorAlertInfo) -> Text? {
guard let subtitle = alertInfo.subtitle else {
return nil
}
return Text(subtitle)
}
}
// MARK: - Previews
@@ -1,20 +1,18 @@
// File created from ScreenTemplate
// $ createScreen.sh Room/NotificationSettings RoomNotificationSettings
/*
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
@@ -19,6 +19,8 @@ import SwiftUI
@available(iOS 14.0.0, *)
struct RoomNotificationSettings: View {
@Environment(\.theme) var theme: ThemeSwiftUI
@ObservedObject var viewModel: RoomNotificationSettingsSwiftUIViewModel
let presentedModally: Bool
@@ -67,6 +69,7 @@ struct RoomNotificationSettings: View {
.onAppear {
viewModel.process(viewAction: .load)
}
.accentColor(theme.colors.accent)
}
}
@@ -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 {
@@ -32,15 +31,10 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
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
}
private var pollEditFormViewModel: PollEditFormViewModelProtocol
// MARK: Public
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
@@ -51,10 +45,21 @@ 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 })))
_pollEditFormViewModel = viewModel
} else {
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
}
let view = PollEditForm(viewModel: viewModel.context)
pollEditFormViewModel = viewModel
pollEditFormHostingController = VectorHostingController(rootView: view)
}
@@ -70,37 +75,87 @@ 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)
self.pollEditFormViewModel.startLoading()
self.parameters.room.sendPollStart(withContent: pollStartContent, localEcho: nil) { [weak self] result in
self.parameters.room.sendPollStart(withContent: pollStartContent, threadId: nil, localEcho: nil) { [weak self] result in
guard let self = self else { return }
self.pollEditFormViewModel.dispatch(action: .stopLoading(nil))
self.pollEditFormViewModel.stopLoading()
self.completion?()
} failure: { [weak self] error in
guard let self = self else { return }
MXLog.error("Failed creating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(error))
self.pollEditFormViewModel.stopLoading(errorAlertType: .failedCreatingPoll)
}
case .update(let details):
guard let pollStartEvent = self.parameters.pollStartEvent else {
fatalError()
}
self.pollEditFormViewModel.startLoading()
guard let oldPollContent = MXEventContentPollStart(fromJSON: pollStartEvent.content) else {
self.pollEditFormViewModel.stopLoading(errorAlertType: .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.stopLoading()
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.stopLoading(errorAlertType: .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,25 @@
import Foundation
import SwiftUI
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
case startLoading
case stopLoading(Error?)
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 PollEditFormViewAction {
@@ -30,11 +43,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 +75,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 +95,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,14 +17,20 @@
import SwiftUI
import Combine
struct PollEditFormViewModelParameters {
let mode: PollEditFormMode
let pollDetails: EditFormPollDetails
}
@available(iOS 14, *)
typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState,
PollEditFormStateAction,
PollEditFormViewAction >
typealias PollEditFormViewModelType = StateStoreViewModel <PollEditFormViewState,
Never,
PollEditFormViewAction>
@available(iOS 14, *)
class PollEditFormViewModel: PollEditFormViewModelType {
class PollEditFormViewModel: PollEditFormViewModelType, PollEditFormViewModelProtocol {
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,37 +68,47 @@ 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
})))
default:
dispatch(action: .viewAction(viewAction))
completion?(.create(buildPollDetails()))
case .update:
completion?(.update(buildPollDetails()))
case .addAnswerOption:
state.bindings.answerOptions.append(PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength))
case .deleteAnswerOption(let answerOption):
state.bindings.answerOptions.removeAll { $0 == answerOption }
}
}
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
}
case .startLoading:
state.showLoadingIndicator = true
break
case .stopLoading(let error):
state.showLoadingIndicator = false
if error != nil {
state.bindings.showsFailureAlert = true
}
// MARK: - PollEditFormViewModelProtocol
func startLoading() {
state.showLoadingIndicator = true
}
func stopLoading(errorAlertType: PollEditFormErrorAlertInfo.AlertType?) {
state.showLoadingIndicator = false
switch errorAlertType {
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
}
}
// 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/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -18,7 +16,15 @@
import Foundation
@available(iOS 14, *)
enum UserSuggestionViewModelResult {
case selectedItemWithIdentifier(String)
protocol PollEditFormViewModelProtocol {
var completion: ((PollEditFormViewModelResult) -> Void)? { get set }
func startLoading()
func stopLoading(errorAlertType: PollEditFormErrorAlertInfo.AlertType?)
}
extension PollEditFormViewModelProtocol {
func stopLoading() {
stopLoading(errorAlertType: nil)
}
}
@@ -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)
}
@@ -108,39 +121,8 @@ struct PollEditForm: View {
}
.accentColor(theme.colors.accent)
.navigationViewStyle(StackNavigationViewStyle())
}
}
@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")
}
.introspectNavigationController { navigationController in
ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar)
}
}
}
@@ -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))
}
}
}
@@ -1,47 +0,0 @@
// 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)))
}
}
@@ -1,132 +0,0 @@
// 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))
}
}
@@ -1,155 +0,0 @@
//
// 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: {})
}
}
}
}
@@ -1,43 +1,41 @@
// 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.
*/
//
// 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 {
struct TimelinePollCoordinatorParameters {
let session: MXSession
let room: MXRoom
let pollStartEvent: MXEvent
}
@available(iOS 14.0, *)
final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDelegate {
final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDelegate {
// MARK: - Properties
// MARK: Private
private let parameters: PollTimelineCoordinatorParameters
private let parameters: TimelinePollCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var pollTimelineViewModel: PollTimelineViewModel!
private var viewModel: TimelinePollViewModelProtocol!
private var cancellables = Set<AnyCancellable>()
// MARK: Public
@@ -48,14 +46,14 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: PollTimelineCoordinatorParameters) throws {
init(parameters: TimelinePollCoordinatorParameters) throws {
self.parameters = parameters
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEvent: parameters.pollStartEvent)
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEventId: parameters.pollStartEvent.eventId)
pollAggregator.delegate = self
pollTimelineViewModel = PollTimelineViewModel(timelinePoll: buildTimelinePollFrom(pollAggregator.poll))
pollTimelineViewModel.callback = { [weak self] result in
viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll))
viewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
@@ -72,12 +70,13 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
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("[PollTimelineCoordinator]] Failed submitting response with error \(String(describing: error))")
MXLog.error("[TimelinePollCoordinator]] Failed submitting response with error \(String(describing: error))")
self.pollTimelineViewModel.dispatch(action: .showAnsweringFailure)
self.viewModel.showAnsweringFailure()
}
}
.store(in: &cancellables)
@@ -89,23 +88,28 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
}
func toPresentable() -> UIViewController {
return VectorHostingController(rootView: PollTimelineView(viewModel: pollTimelineViewModel.context))
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, localEcho: nil, success: nil) { [weak self] error in
self?.pollTimelineViewModel.dispatch(action: .showClosingFailure)
parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in
self?.viewModel.showClosingFailure()
}
}
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
pollTimelineViewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll))
}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
@@ -124,20 +128,28 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
// 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 {
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePollDetails {
let answerOptions = poll.answerOptions.map { pollAnswerOption in
TimelineAnswerOption(id: pollAnswerOption.id,
TimelinePollAnswerOption(id: pollAnswerOption.id,
text: pollAnswerOption.text,
count: pollAnswerOption.count,
winner: pollAnswerOption.isWinner,
selected: pollAnswerOption.isCurrentUserSelection)
}
return TimelinePoll(question: poll.text,
return TimelinePollDetails(question: poll.text,
answerOptions: answerOptions,
closed: poll.isClosed,
totalAnswerCount: poll.totalAnswerCount,
type: (poll.kind == .disclosed ? .disclosed : .undisclosed),
maxAllowedSelections: poll.maxAllowedSelections)
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
}
}
@@ -17,11 +17,11 @@
import Foundation
@available(iOS 14, *)
class PollTimelineProvider {
static let shared = PollTimelineProvider()
class TimelinePollProvider {
static let shared = TimelinePollProvider()
var session: MXSession?
var coordinatorsForEventIdentifiers = [String: PollTimelineCoordinator]()
var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]()
private init() {
@@ -29,7 +29,7 @@ class PollTimelineProvider {
/// 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? {
func buildTimelinePollViewForEvent(_ event: MXEvent) -> UIView? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
@@ -38,8 +38,8 @@ class PollTimelineProvider {
return coordinator.toPresentable().view
}
let parameters = PollTimelineCoordinatorParameters(session: session, room: room, pollStartEvent: event)
guard let coordinator = try? PollTimelineCoordinator(parameters: parameters) else {
let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event)
guard let coordinator = try? TimelinePollCoordinator(parameters: parameters) else {
return nil
}
@@ -49,7 +49,7 @@ class PollTimelineProvider {
}
/// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
func pollTimelineCoordinatorForEventIdentifier(_ eventIdentifier: String) -> PollTimelineCoordinator? {
func timelinePollCoordinatorForEventIdentifier(_ eventIdentifier: String) -> TimelinePollCoordinator? {
return coordinatorsForEventIdentifiers[eventIdentifier]
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -20,7 +18,7 @@ import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineUITests: XCTestCase {
class TimelinePollUITests: XCTestCase {
private var app: XCUIApplication!
@@ -31,8 +29,8 @@ class PollTimelineUITests: XCTestCase {
app.launch()
}
func testOpenPoll() {
app.goToScreenWithIdentifier(MockPollTimelineScreenState.open.title)
func testOpenDisclosedPoll() {
app.goToScreenWithIdentifier(MockTimelinePollScreenState.openDisclosed.title)
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
@@ -69,9 +67,48 @@ class PollTimelineUITests: XCTestCase {
XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
}
func testClosedPoll() {
app.goToScreenWithIdentifier(MockPollTimelineScreenState.closed.title)
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)
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -22,24 +20,25 @@ import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineViewModelTests: XCTestCase {
var viewModel: PollTimelineViewModel!
var context: PollTimelineViewModelType.Context!
class TimelinePollViewModelTests: XCTestCase {
var viewModel: TimelinePollViewModel!
var context: TimelinePollViewModelType.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 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 = TimelinePoll(question: "Question",
answerOptions: answerOptions,
closed: false,
totalAnswerCount: 3,
type: .disclosed,
maxAllowedSelections: 1)
let timelinePoll = TimelinePollDetails(question: "Question",
answerOptions: answerOptions,
closed: false,
totalAnswerCount: 3,
type: .disclosed,
maxAllowedSelections: 1,
hasBeenEdited: false)
viewModel = PollTimelineViewModel(timelinePoll: timelinePoll)
viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll)
context = viewModel.context
}
@@ -86,7 +85,7 @@ class PollTimelineViewModelTests: XCTestCase {
}
func testClosedSelection() {
context.viewState.poll.closed = true
viewModel.state.poll.closed = true
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
@@ -97,7 +96,7 @@ class PollTimelineViewModelTests: XCTestCase {
}
func testSingleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
@@ -107,7 +106,7 @@ class PollTimelineViewModelTests: XCTestCase {
}
func testSingleReselectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
@@ -118,7 +117,7 @@ class PollTimelineViewModelTests: XCTestCase {
}
func testMultipleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -19,20 +17,13 @@
import Foundation
import SwiftUI
typealias PollTimelineViewModelCallback = ((PollTimelineViewModelResult) -> Void)
typealias TimelinePollViewModelCallback = ((TimelinePollViewModelResult) -> Void)
enum PollTimelineStateAction {
case viewAction(PollTimelineViewAction, PollTimelineViewModelCallback?)
case updateWithPoll(TimelinePoll)
case showAnsweringFailure
case showClosingFailure
}
enum PollTimelineViewAction {
enum TimelinePollViewAction {
case selectAnswerOptionWithIdentifier(String)
}
enum PollTimelineViewModelResult {
enum TimelinePollViewModelResult {
case selectedAnswerOptionsWithIdentifiers([String])
}
@@ -41,7 +32,7 @@ enum TimelinePollType {
case undisclosed
}
class TimelineAnswerOption: Identifiable {
struct TimelinePollAnswerOption: Identifiable {
var id: String
var text: String
var count: UInt
@@ -57,35 +48,67 @@ class TimelineAnswerOption: Identifiable {
}
}
class TimelinePoll {
extension MutableCollection where Element == TimelinePollAnswerOption {
mutating func updateEach(_ update: (inout Element) -> Void) {
for index in indices {
update(&self[index])
}
}
}
struct TimelinePollDetails {
var question: String
var answerOptions: [TimelineAnswerOption]
var answerOptions: [TimelinePollAnswerOption]
var closed: Bool
var totalAnswerCount: UInt
var type: TimelinePollType
var maxAllowedSelections: UInt
var hasBeenEdited: Bool = true
init(question: String, answerOptions: [TimelineAnswerOption], closed: Bool, totalAnswerCount: UInt, type: TimelinePollType, maxAllowedSelections: UInt) {
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 PollTimelineViewState: BindableState {
var poll: TimelinePoll
var bindings: PollTimelineViewStateBindings
struct TimelinePollViewState: BindableState {
var poll: TimelinePollDetails
var bindings: TimelinePollViewStateBindings
}
struct PollTimelineViewStateBindings {
var showsAnsweringFailureAlert: Bool = false
var showsClosingFailureAlert: Bool = false
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,136 @@
//
// 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,
Never,
TimelinePollViewAction>
@available(iOS 14, *)
class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: TimelinePollViewModelCallback?
// MARK: - Setup
init(timelinePollDetails: TimelinePollDetails) {
super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings()))
}
// MARK: - Public
override func process(viewAction: TimelinePollViewAction) {
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(selectedAnswerIdentifier: identifier, callback: completion)
} else {
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion)
}
}
}
// MARK: - TimelinePollViewModelProtocol
func updateWithPollDetails(_ pollDetails: TimelinePollDetails) {
state.poll = pollDetails
}
func showAnsweringFailure() {
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedSubmittingAnswer,
title: VectorL10n.pollTimelineVoteNotRegisteredTitle,
subtitle: VectorL10n.pollTimelineVoteNotRegisteredSubtitle)
}
func showClosingFailure() {
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedClosingPoll,
title: VectorL10n.pollTimelineNotClosedTitle,
subtitle: VectorL10n.pollTimelineNotClosedSubtitle)
}
// MARK: - Private
func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
state.poll.answerOptions.updateEach { answerOption in
if answerOption.selected {
answerOption.selected = false
if(state.poll.answerOptions.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)
}
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
}
state.poll.answerOptions.updateEach { answerOption in
if (answerOption.id != selectedAnswerIdentifier) {
return
}
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)
}
func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -18,7 +16,12 @@
import Foundation
@available(iOS 14, *)
enum UserSuggestionViewAction {
case selectedItem(UserSuggestionViewStateItem)
protocol TimelinePollViewModelProtocol {
@available(iOS 14, *)
var context: TimelinePollViewModelType.Context { get }
var completion: ((TimelinePollViewModelResult) -> Void)? { get set }
func updateWithPollDetails(_ pollDetails: TimelinePollDetails)
func showAnsweringFailure()
func showClosingFailure()
}
@@ -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)
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,7 +17,7 @@
import SwiftUI
@available(iOS 14.0, *)
struct PollTimelineView: View {
struct TimelinePollView: View {
// MARK: - Properties
@@ -29,29 +27,26 @@ struct PollTimelineView: View {
// MARK: Public
@ObservedObject var viewModel: PollTimelineViewModel.Context
@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
PollTimelineAnswerOptionButton(answerOption: answerOption,
pollClosed: poll.closed,
showResults: shouldDiscloseResults,
totalAnswerCount: poll.totalAnswerCount) {
TimelinePollAnswerOptionButton(poll: poll, answerOption: answerOption) {
viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
}
}
.alert(isPresented: $viewModel.showsClosingFailureAlert) {
Alert(title: Text(VectorL10n.pollTimelineNotClosedTitle),
message: Text(VectorL10n.pollTimelineNotClosedSubtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
}
.disabled(poll.closed)
.fixedSize(horizontal: false, vertical: true)
@@ -59,14 +54,14 @@ struct PollTimelineView: View {
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.ok)))
}
}
.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 {
@@ -84,32 +79,26 @@ struct PollTimelineView: View {
case 0:
return VectorL10n.pollTimelineTotalNoVotes
case 1:
return (poll.hasCurrentUserVoted ?
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
VectorL10n.pollTimelineTotalOneVote :
VectorL10n.pollTimelineTotalOneVoteNotVoted)
default:
return (poll.hasCurrentUserVoted ?
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
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
}
private var editedText: String {
viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct PollTimelineView_Previews: PreviewProvider {
static let stateRenderer = MockPollTimelineScreenState.stateRenderer
struct TimelinePollView_Previews: PreviewProvider {
static let stateRenderer = MockTimelinePollScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
@@ -1,20 +1,18 @@
// 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.
*/
//
// 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
@@ -25,6 +23,11 @@ protocol UserSuggestionCoordinatorDelegate: AnyObject {
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?)
}
struct UserSuggestionCoordinatorParameters {
let mediaManager: MXMediaManager
let room: MXRoom
}
@available(iOS 14.0, *)
final class UserSuggestionCoordinator: Coordinator, Presentable {
@@ -55,11 +58,12 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room)
userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider)
userSuggestionViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: userSuggestionService)
let view = UserSuggestionList(viewModel: userSuggestionViewModel.context)
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
let view = UserSuggestionList(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
userSuggestionViewModel = viewModel
userSuggestionHostingController = VectorHostingController(rootView: view)
userSuggestionViewModel.completion = { [weak self] result in
@@ -92,7 +96,6 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
}
}
@available(iOS 14.0, *)
private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol {
private let room: MXRoom
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -19,19 +17,16 @@
import Foundation
import Combine
@available(iOS 14.0, *)
struct RoomMembersProviderMember {
var userId: String
var displayName: String
var avatarUrl: String
}
@available(iOS 14.0, *)
protocol RoomMembersProviderProtocol {
func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void)
}
@available(iOS 14.0, *)
struct UserSuggestionServiceItem: UserSuggestionItemProtocol {
let userId: String
let displayName: String?
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -19,7 +17,6 @@
import Foundation
import Combine
@available(iOS 14.0, *)
protocol UserSuggestionItemProtocol: Avatarable {
var userId: String { get }
var displayName: String? { get }
@@ -38,7 +35,6 @@ protocol UserSuggestionServiceProtocol {
// MARK: Avatarable
@available(iOS 14.0, *)
extension UserSuggestionItemProtocol {
var mxContentUri: String? {
avatarUrl
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -17,16 +15,21 @@
//
import Foundation
import Combine
@available(iOS 14.0, *)
enum UserSuggestionViewAction {
case selectedItem(UserSuggestionViewStateItem)
}
enum UserSuggestionViewModelResult {
case selectedItemWithIdentifier(String)
}
struct UserSuggestionViewStateItem: Identifiable {
let id: String
let avatar: AvatarInputProtocol?
let displayName: String?
}
@available(iOS 14.0, *)
struct UserSuggestionViewState: BindableState {
var items: [UserSuggestionViewStateItem]
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -26,12 +24,12 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
static private var members: [RoomMembersProviderMember]!
var screenType: Any.Type {
MockUserSuggestionScreenState.self
UserSuggestionList.self
}
var screenView: ([Any], AnyView) {
let service = UserSuggestionService(roomMemberProvider: self)
let listViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: service)
let listViewModel = UserSuggestionViewModel(userSuggestionService: service)
let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in
service.processTextMessage(textMessage)
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -19,11 +17,12 @@
import SwiftUI
import Combine
@available(iOS 14, *)
@available(iOS 14.0, *)
typealias UserSuggestionViewModelType = StateStoreViewModel <UserSuggestionViewState,
UserSuggestionStateAction,
Never,
UserSuggestionViewAction>
@available(iOS 14, *)
@available(iOS 14.0, *)
class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol {
// MARK: - Properties
@@ -37,30 +36,21 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
var completion: ((UserSuggestionViewModelResult) -> Void)?
// MARK: - Setup
static func makeUserSuggestionViewModel(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewModelProtocol {
return UserSuggestionViewModel(userSuggestionService: userSuggestionService)
}
private init(userSuggestionService: UserSuggestionServiceProtocol) {
init(userSuggestionService: UserSuggestionServiceProtocol) {
self.userSuggestionService = userSuggestionService
super.init(initialViewState: Self.defaultState(userSuggestionService: userSuggestionService))
setupItemsObserving()
}
private func setupItemsObserving() {
let updatePublisher = userSuggestionService.items
.map(UserSuggestionStateAction.updateWithItems)
.eraseToAnyPublisher()
dispatch(actionPublisher: updatePublisher)
}
private static func defaultState(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewState {
let viewStateItems = userSuggestionService.items.value.map { suggestionItem in
let items = userSuggestionService.items.value.map { suggestionItem in
return UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName)
}
return UserSuggestionViewState(items: viewStateItems)
super.init(initialViewState: UserSuggestionViewState(items: items))
userSuggestionService.items.sink { items in
self.state.items = items.map({ item in
UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName)
})
}.store(in: &cancellables)
}
// MARK: - Public
@@ -71,13 +61,4 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
completion?(.selectedItemWithIdentifier(item.id))
}
}
override class func reducer(state: inout UserSuggestionViewState, action: UserSuggestionStateAction) {
switch action {
case .updateWithItems(let items):
state.items = items.map({ item in
UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName)
})
}
}
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -18,7 +16,6 @@
import Foundation
struct UserSuggestionCoordinatorParameters {
let mediaManager: MXMediaManager
let room: MXRoom
protocol UserSuggestionViewModelProtocol {
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
}
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -18,7 +18,7 @@ import SwiftUI
@available(iOS 14.0, *)
struct UserSuggestionListWithInputViewModel {
let listViewModel: UserSuggestionViewModelProtocol
let listViewModel: UserSuggestionViewModel
let callback: (String)->()
}
@@ -1,29 +0,0 @@
// 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
@available(iOS 14, *)
protocol UserSuggestionViewModelProtocol {
static func makeUserSuggestionViewModel(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewModelProtocol
var context: UserSuggestionViewModelType.Context { get }
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
}