Made StateStoreViewModel state mutable and removed the reducer for all the features using it.

This commit is contained in:
Stefan Ceriu
2022-01-28 12:58:31 +02:00
committed by Stefan Ceriu
parent fc9e95aee8
commit 313b05485a
42 changed files with 324 additions and 431 deletions
@@ -33,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
@@ -58,7 +53,7 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
let view = LocationSharingView(context: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
_locationSharingViewModel = viewModel
locationSharingViewModel = viewModel
locationSharingHostingController = VectorHostingController(rootView: view)
}
@@ -81,20 +76,18 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
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)
}
}
@@ -19,31 +19,23 @@ import SwiftUI
import Combine
import CoreLocation
enum LocationSharingViewError {
case failedLoadingMap
case failedLocatingUser
case invalidLocationAuthorization
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)
}
enum LocationSharingViewError {
case failedLoadingMap
case failedLocatingUser
case invalidLocationAuthorization
case failedSharingLocation
}
@available(iOS 14, *)
struct LocationSharingViewState: BindableState {
let tileServerMapURL: URL
@@ -80,6 +72,7 @@ struct LocationSharingErrorAlertInfo: 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
}
@@ -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
@@ -41,7 +41,7 @@ class LocationSharingViewModel: LocationSharingViewModelType {
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 = LocationSharingErrorAlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName) ,
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .failedLocatingUser:
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .invalidLocationAuthorization:
state.bindings.alertInfo = LocationSharingErrorAlertInfo(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 = LocationSharingErrorAlertInfo(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
}
}
}
@@ -16,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)
}
@@ -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)
@@ -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
}
@@ -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?()
})
@@ -93,6 +96,14 @@ struct LocationSharingView: View {
ActivityIndicator()
}
}
private func subtitleTextForAlertInfo(_ alertInfo: LocationSharingErrorAlertInfo) -> Text? {
guard let subtitle = alertInfo.subtitle else {
return nil
}
return Text(subtitle)
}
}
// MARK: - Previews
@@ -31,12 +31,7 @@ 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
@@ -64,7 +59,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
let view = PollEditForm(viewModel: viewModel.context)
_pollEditFormViewModel = viewModel
pollEditFormViewModel = viewModel
pollEditFormHostingController = VectorHostingController(rootView: view)
}
@@ -84,18 +79,18 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
let pollStartContent = self.buildPollContentWithDetails(details)
self.pollEditFormViewModel.dispatch(action: .startLoading)
self.pollEditFormViewModel.startLoading()
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(.failedCreatingPoll))
self.pollEditFormViewModel.stopLoading(errorAlertType: .failedCreatingPoll)
}
case .update(let details):
@@ -103,10 +98,10 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
fatalError()
}
self.pollEditFormViewModel.dispatch(action: .startLoading)
self.pollEditFormViewModel.startLoading()
guard let oldPollContent = MXEventContentPollStart(fromJSON: pollStartEvent.content) else {
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
self.pollEditFormViewModel.stopLoading(errorAlertType: .failedUpdatingPoll)
return
}
@@ -117,13 +112,13 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
newContent: newPollContent, 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 updating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
self.pollEditFormViewModel.stopLoading(errorAlertType: .failedUpdatingPoll)
}
}
}
@@ -38,12 +38,6 @@ enum PollEditFormMode {
case editing
}
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
case startLoading
case stopLoading(PollEditFormErrorAlertInfo.AlertType?)
}
enum PollEditFormViewAction {
case addAnswerOption
case deleteAnswerOption(PollEditFormAnswerOption)
@@ -23,11 +23,11 @@ struct PollEditFormViewModelParameters {
}
@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
@@ -71,40 +71,32 @@ class PollEditFormViewModel: PollEditFormViewModelType {
completion?(.create(buildPollDetails()))
case .update:
completion?(.update(buildPollDetails()))
default:
dispatch(action: .viewAction(viewAction))
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
switch error {
case .failedCreatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedCreatingPoll,
title: VectorL10n.pollEditFormPostFailureTitle,
subtitle: VectorL10n.pollEditFormPostFailureSubtitle)
case .failedUpdatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedUpdatingPoll,
title: VectorL10n.pollEditFormUpdateFailureTitle,
subtitle: VectorL10n.pollEditFormUpdateFailureSubtitle)
case .none:
break
}
// 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
}
}
@@ -115,8 +107,8 @@ class PollEditFormViewModel: PollEditFormViewModelType {
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
}))
let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}))
}
}
@@ -16,7 +16,15 @@
import Foundation
@available(iOS 14, *)
enum UserSuggestionViewAction {
case selectedItem(UserSuggestionViewStateItem)
protocol PollEditFormViewModelProtocol {
var completion: ((PollEditFormViewModelResult) -> Void)? { get set }
func startLoading()
func stopLoading(errorAlertType: PollEditFormErrorAlertInfo.AlertType?)
}
extension PollEditFormViewModelProtocol {
func stopLoading() {
stopLoading(errorAlertType: nil)
}
}
@@ -35,7 +35,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var viewModel: TimelinePollViewModel!
private var viewModel: TimelinePollViewModelProtocol!
private var cancellables = Set<AnyCancellable>()
// MARK: Public
@@ -53,7 +53,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
pollAggregator.delegate = self
viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll))
viewModel.callback = { [weak self] result in
viewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
@@ -76,7 +76,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
MXLog.error("[TimelinePollCoordinator]] Failed submitting response with error \(String(describing: error))")
self.viewModel.dispatch(action: .showAnsweringFailure)
self.viewModel.showAnsweringFailure()
}
}
.store(in: &cancellables)
@@ -102,14 +102,14 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
func endPoll() {
parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in
self?.viewModel.dispatch(action: .showClosingFailure)
self?.viewModel.showClosingFailure()
}
}
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
viewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll))
}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
@@ -85,7 +85,7 @@ class TimelinePollViewModelTests: XCTestCase {
}
func testClosedSelection() {
context.viewState.poll.closed = true
viewModel.state.poll.closed = true
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
@@ -96,7 +96,7 @@ class TimelinePollViewModelTests: XCTestCase {
}
func testSingleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
@@ -106,7 +106,7 @@ class TimelinePollViewModelTests: XCTestCase {
}
func testSingleReselectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
@@ -117,7 +117,7 @@ class TimelinePollViewModelTests: XCTestCase {
}
func testMultipleSelectionOnMax2Allowed() {
context.viewState.poll.maxAllowedSelections = 2
viewModel.state.poll.maxAllowedSelections = 2
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
@@ -19,13 +19,6 @@ import SwiftUI
typealias TimelinePollViewModelCallback = ((TimelinePollViewModelResult) -> Void)
enum TimelinePollStateAction {
case viewAction(TimelinePollViewAction, TimelinePollViewModelCallback?)
case updateWithPoll(TimelinePollDetails)
case showAnsweringFailure
case showClosingFailure
}
enum TimelinePollViewAction {
case selectAnswerOptionWithIdentifier(String)
}
@@ -39,7 +32,7 @@ enum TimelinePollType {
case undisclosed
}
class TimelinePollAnswerOption: Identifiable {
struct TimelinePollAnswerOption: Identifiable {
var id: String
var text: String
var count: UInt
@@ -55,7 +48,15 @@ class TimelinePollAnswerOption: Identifiable {
}
}
class TimelinePollDetails {
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: [TimelinePollAnswerOption]
var closed: Bool
@@ -19,10 +19,10 @@ import Combine
@available(iOS 14, *)
typealias TimelinePollViewModelType = StateStoreViewModel<TimelinePollViewState,
TimelinePollStateAction,
Never,
TimelinePollViewAction>
@available(iOS 14, *)
class TimelinePollViewModel: TimelinePollViewModelType {
class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelProtocol {
// MARK: - Properties
@@ -30,7 +30,7 @@ class TimelinePollViewModel: TimelinePollViewModelType {
// MARK: Public
var callback: TimelinePollViewModelCallback?
var completion: TimelinePollViewModelCallback?
// MARK: - Setup
@@ -42,49 +42,47 @@ class TimelinePollViewModel: TimelinePollViewModelType {
override func process(viewAction: TimelinePollViewAction) {
switch viewAction {
case .selectAnswerOptionWithIdentifier(_):
dispatch(action: .viewAction(viewAction, callback))
}
}
override class func reducer(state: inout TimelinePollViewState, action: TimelinePollStateAction) {
switch action {
case .viewAction(let viewAction, let callback):
switch viewAction {
// Update local state. An update will be pushed from the coordinator once sent.
case .selectAnswerOptionWithIdentifier(let identifier):
guard !state.poll.closed else {
return
}
if (state.poll.maxAllowedSelections == 1) {
updateSingleSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
} else {
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
}
// 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)
}
case .updateWithPoll(let poll):
state.poll = poll
case .showAnsweringFailure:
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedSubmittingAnswer,
title: VectorL10n.pollTimelineVoteNotRegisteredTitle,
subtitle: VectorL10n.pollTimelineVoteNotRegisteredSubtitle)
case .showClosingFailure:
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedClosingPoll,
title: VectorL10n.pollTimelineNotClosedTitle,
subtitle: VectorL10n.pollTimelineNotClosedSubtitle)
}
}
// MARK: - 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
static func updateSingleSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
for answerOption in state.poll.answerOptions {
func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
state.poll.answerOptions.updateEach { answerOption in
if answerOption.selected {
answerOption.selected = false
if(answerOption.count > 0) {
if(state.poll.answerOptions.count > 0) {
answerOption.count = answerOption.count - 1
state.poll.totalAnswerCount -= 1
}
@@ -100,7 +98,7 @@ class TimelinePollViewModel: TimelinePollViewModelType {
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
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
@@ -109,7 +107,11 @@ class TimelinePollViewModel: TimelinePollViewModelType {
return
}
for answerOption in state.poll.answerOptions where answerOption.id == selectedAnswerIdentifier {
state.poll.answerOptions.updateEach { answerOption in
if (answerOption.id != selectedAnswerIdentifier) {
return
}
if answerOption.selected {
answerOption.selected = false
answerOption.count -= 1
@@ -124,7 +126,7 @@ class TimelinePollViewModel: TimelinePollViewModelType {
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}
@@ -16,7 +16,12 @@
import Foundation
struct UserSuggestionCoordinatorParameters {
let mediaManager: MXMediaManager
let room: MXRoom
protocol TimelinePollViewModelProtocol {
@available(iOS 14, *)
var context: TimelinePollViewModelType.Context { get }
var completion: ((TimelinePollViewModelResult) -> Void)? { get set }
func updateWithPollDetails(_ pollDetails: TimelinePollDetails)
func showAnsweringFailure()
func showClosingFailure()
}
@@ -23,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 {
@@ -53,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
@@ -90,7 +96,6 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
}
}
@available(iOS 14.0, *)
private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol {
private let room: MXRoom
@@ -1,22 +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 Foundation
@available(iOS 14, *)
enum UserSuggestionViewModelResult {
case selectedItemWithIdentifier(String)
}
@@ -17,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?
@@ -17,7 +17,6 @@
import Foundation
import Combine
@available(iOS 14.0, *)
protocol UserSuggestionItemProtocol: Avatarable {
var userId: String { get }
var displayName: String? { get }
@@ -36,7 +35,6 @@ protocol UserSuggestionServiceProtocol {
// MARK: Avatarable
@available(iOS 14.0, *)
extension UserSuggestionItemProtocol {
var mxContentUri: String? {
avatarUrl
@@ -15,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]
}
@@ -29,7 +29,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
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)
@@ -17,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
@@ -35,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
@@ -69,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)
})
}
}
}
@@ -16,12 +16,6 @@
import Foundation
@available(iOS 14, *)
protocol UserSuggestionViewModelProtocol {
static func makeUserSuggestionViewModel(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewModelProtocol
var context: UserSuggestionViewModelType.Context { get }
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
}
@@ -18,7 +18,7 @@ import SwiftUI
@available(iOS 14.0, *)
struct UserSuggestionListWithInputViewModel {
let listViewModel: UserSuggestionViewModelProtocol
let listViewModel: UserSuggestionViewModel
let callback: (String)->()
}