diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 0cc59b0e5..4f37740fe 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1905,6 +1905,10 @@ Tap the + to start adding people."; "location_sharing_share_action" = "Share"; +"location_sharing_post_failure_title" = "We couldn’t send your location"; + +"location_sharing_post_failure_subtitle" = "%@ could not send your location. Please try again later."; + "location_sharing_loading_map_error_title" = "%@ could not load the map. Please try again later."; "location_sharing_locating_user_error_title" = "%@ could not access your location. Please try again later."; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 992396767..e55abd47e 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2227,6 +2227,14 @@ public class VectorL10n: NSObject { public static var locationSharingOpenGoogleMaps: String { return VectorL10n.tr("Vector", "location_sharing_open_google_maps") } + /// %@ could not send your location. Please try again later. + public static func locationSharingPostFailureSubtitle(_ p1: String) -> String { + return VectorL10n.tr("Vector", "location_sharing_post_failure_subtitle", p1) + } + /// We couldn’t send your location + public static var locationSharingPostFailureTitle: String { + return VectorL10n.tr("Vector", "location_sharing_post_failure_title") + } /// Location sharing public static var locationSharingSettingsHeader: String { return VectorL10n.tr("Vector", "location_sharing_settings_header") diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift index 99f96bce8..2cfdb6e0f 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift @@ -16,9 +16,6 @@ import Foundation -// The state is never modified so this is unnecessary. -enum AnalyticsPromptStateAction { } - enum AnalyticsPromptViewAction { /// Enable analytics. case enable diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptViewModel.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptViewModel.swift index 971929ab4..7cd852f71 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptViewModel.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias AnalyticsPromptViewModelType = StateStoreViewModel @available(iOS 14, *) class AnalyticsPromptViewModel: AnalyticsPromptViewModelType { @@ -54,10 +54,6 @@ class AnalyticsPromptViewModel: AnalyticsPromptViewModelType { openTermsURL() } } - - override class func reducer(state: inout AnalyticsPromptViewState, action: AnalyticsPromptStateAction) { - // There is no mutable state to reduce :) - } /// Enable analytics. The call to the Analytics class is made in the completion. private func enable() { diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift index 915859165..efde322fb 100644 --- a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -92,9 +92,9 @@ class StateStoreViewModel { /// Constrained interface for passing to Views. var context: Context - /// State can be read within the 'ViewModel' but not modified outside of the reducer. var state: State { - context.viewState + get { context.viewState } + set { context.viewState = newValue } } // MARK: Setup @@ -110,12 +110,14 @@ class StateStoreViewModel { /// Send state actions to modify the state within the reducer. /// - Parameter action: The state action to send to the reducer. + @available(*, deprecated, message: "Mutate state directly instead") func dispatch(action: StateAction) { Self.reducer(state: &context.viewState, action: action) } /// Send state actions from a publisher to modify the state within the reducer. /// - Parameter actionPublisher: The publisher that produces actions to be sent to the reducer + @available(*, deprecated, message: "Mutate state directly instead") func dispatch(actionPublisher: AnyPublisher) { actionPublisher.sink { [weak self] action in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenModels.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenModels.swift index 73f7f3ef4..d24b7679d 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenModels.swift @@ -30,10 +30,6 @@ struct OnboardingSplashScreenPageContent { // MARK: View model -enum OnboardingSplashScreenStateAction { - case viewAction(OnboardingSplashScreenViewAction) -} - enum OnboardingSplashScreenViewModelResult { case register case login diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenViewModel.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenViewModel.swift index f013bda1c..c51b843dc 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/OnboardingSplashScreenViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias OnboardingSplashScreenViewModelType = StateStoreViewModel protocol OnboardingSplashScreenViewModelProtocol { @@ -54,31 +54,18 @@ class OnboardingSplashScreenViewModel: OnboardingSplashScreenViewModelType, Onbo register() case .login: login() - case .nextPage, .previousPage, .hiddenPage: - dispatch(action: .viewAction(viewAction)) + case .nextPage: + // Wrap back round to the first page index when reaching the end. + state.bindings.pageIndex = (state.bindings.pageIndex + 1) % state.content.count + case .previousPage: + // Prevent the hidden page at index -1 from being shown. + state.bindings.pageIndex = max(0, (state.bindings.pageIndex - 1)) + case .hiddenPage: + // Hidden page for a nicer animation when looping back to the start. + state.bindings.pageIndex = -1 } } - override class func reducer(state: inout OnboardingSplashScreenViewState, action: OnboardingSplashScreenStateAction) { - switch action { - case .viewAction(let viewAction): - switch viewAction { - case .nextPage: - // Wrap back round to the first page index when reaching the end. - state.bindings.pageIndex = (state.bindings.pageIndex + 1) % state.content.count - case .previousPage: - // Prevent the hidden page at index -1 from being shown. - state.bindings.pageIndex = max(0, (state.bindings.pageIndex - 1)) - case .hiddenPage: - // Hidden page for a nicer animation when looping back to the start. - state.bindings.pageIndex = -1 - case .login, .register: - break - } - } - UILog.debug("[OnboardingSplashScreenViewModel] reducer with action \(action) produced state: \(state)") - } - private func register() { completion?(.register) } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Test/Unit/OnboardingSplashScreenViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Test/Unit/OnboardingSplashScreenViewModelTests.swift index 78bb372d4..c247b4027 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Test/Unit/OnboardingSplashScreenViewModelTests.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Test/Unit/OnboardingSplashScreenViewModelTests.swift @@ -21,5 +21,5 @@ import Combine @available(iOS 14.0, *) class OnboardingSplashScreenViewModelTests: XCTestCase { - // TODO: Check for any useful tests when finished + } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift index 471187dd5..bb8de9124 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Coordinator/LocationSharingCoordinator.swift @@ -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) } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift index 88b0ee9cb..f9b188c81 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift @@ -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 } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift index 40b750f85..821b525bb 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModel.swift @@ -19,11 +19,11 @@ import Combine import CoreLocation @available(iOS 14, *) -typealias LocationSharingViewModelType = StateStoreViewModel< LocationSharingViewState, - LocationSharingStateAction, - LocationSharingViewAction > +typealias LocationSharingViewModelType = StateStoreViewModel @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 } } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionStateAction.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModelProtocol.swift similarity index 65% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionStateAction.swift rename to RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModelProtocol.swift index f21630348..38fbde4d4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionStateAction.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingViewModelProtocol.swift @@ -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) + } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Test/UI/LocationSharingUITests.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Test/UI/LocationSharingUITests.swift index 6e68f3556..d5040db2f 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/Test/UI/LocationSharingUITests.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Test/UI/LocationSharingUITests.swift @@ -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) } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift b/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift index e3371727c..c091b1817 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/Test/Unit/LocationSharingViewModelTests.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift index 53064ae54..5a782d077 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift @@ -68,7 +68,7 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate { private let avatarData: AvatarInputProtocol private let errorSubject: PassthroughSubject - @Binding var userLocation: CLLocationCoordinate2D? + @Binding private var userLocation: CLLocationCoordinate2D? init(avatarData: AvatarInputProtocol, errorSubject: PassthroughSubject, @@ -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 } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift index 9bc932536..be7ffe441 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift index 7476ac01a..2878648c8 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift @@ -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) } } } diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift index 346ba3d57..75fbf84ab 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift index 022b4b727..ec6ca5e09 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift @@ -23,11 +23,11 @@ struct PollEditFormViewModelParameters { } @available(iOS 14, *) -typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState, - PollEditFormStateAction, - PollEditFormViewAction > +typealias PollEditFormViewModelType = StateStoreViewModel @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 + })) } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewAction.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModelProtocol.swift similarity index 64% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewAction.swift rename to RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModelProtocol.swift index d08fa62ec..da2824270 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewAction.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModelProtocol.swift @@ -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) + } } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 13d1f4233..9502a6205 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -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() // 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) { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift index 3de360418..3f3216356 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift @@ -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")) diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift index da8abd7eb..e6a22e5e8 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift index f28c7185c..e0574c0d7 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModel.swift @@ -19,10 +19,10 @@ import Combine @available(iOS 14, *) typealias TimelinePollViewModelType = StateStoreViewModel @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 } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorParameters.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift similarity index 64% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorParameters.swift rename to RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift index bd9d54e8b..adaf6ad15 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorParameters.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollViewModelProtocol.swift @@ -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() } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index 4163d0668..2c08b20a9 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewModelResult.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewModelResult.swift deleted file mode 100644 index b15a983b6..000000000 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewModelResult.swift +++ /dev/null @@ -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) -} diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index a2a59ec86..5ac56294f 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift @@ -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? diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift index 44009d0c8..d7be97eb4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift similarity index 83% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewState.swift rename to RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift index 0b992b6d1..8bc107a3c 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Model/UserSuggestionViewState.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionModels.swift @@ -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] } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/Mock/MockUserSuggestionScreenState.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift similarity index 94% rename from RiotSwiftUI/Modules/Room/UserSuggestion/Service/Mock/MockUserSuggestionScreenState.swift rename to RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift index 16941ab76..9c290f6ae 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/Mock/MockUserSuggestionScreenState.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionScreenState.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift similarity index 56% rename from RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModel.swift rename to RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 62ba522e4..51a3aa7f7 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -17,11 +17,12 @@ import SwiftUI import Combine -@available(iOS 14, *) +@available(iOS 14.0, *) typealias UserSuggestionViewModelType = StateStoreViewModel -@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) - }) - } - } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift similarity index 76% rename from RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModelProtocol.swift rename to RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift index 169b89735..7a04cf8c4 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/ViewModel/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift index 40573b75c..6f26c19ee 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionListWithInput.swift @@ -18,7 +18,7 @@ import SwiftUI @available(iOS 14.0, *) struct UserSuggestionListWithInputViewModel { - let listViewModel: UserSuggestionViewModelProtocol + let listViewModel: UserSuggestionViewModel let callback: (String)->() } diff --git a/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenModels.swift b/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenModels.swift index 5de592c73..2238fc709 100644 --- a/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenModels.swift +++ b/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenModels.swift @@ -47,10 +47,6 @@ extension TemplateSimpleScreenPromptType: Identifiable, CaseIterable { // MARK: View model -enum TemplateSimpleScreenStateAction { - case viewAction(TemplateSimpleScreenViewAction) -} - enum TemplateSimpleScreenViewModelResult { case accept case cancel diff --git a/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenViewModel.swift index dd1664969..b67286866 100644 --- a/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleScreenExample/TemplateSimpleScreenViewModel.swift @@ -18,7 +18,7 @@ import SwiftUI @available(iOS 14, *) typealias TemplateSimpleScreenViewModelType = StateStoreViewModel @available(iOS 14, *) class TemplateSimpleScreenViewModel: TemplateSimpleScreenViewModelType, TemplateSimpleScreenViewModelProtocol { @@ -45,23 +45,10 @@ class TemplateSimpleScreenViewModel: TemplateSimpleScreenViewModelType, Template completion?(.accept) case .cancel: completion?(.cancel) - case .incrementCount, .decrementCount: - dispatch(action: .viewAction(viewAction)) + case .incrementCount: + state.count += 1 + case .decrementCount: + state.count -= 1 } } - - override class func reducer(state: inout TemplateSimpleScreenViewState, action: TemplateSimpleScreenStateAction) { - switch action { - case .viewAction(let viewAction): - switch viewAction { - case .incrementCount: - state.count += 1 - case .decrementCount: - state.count -= 1 - case .accept, .cancel: - break - } - } - UILog.debug("[TemplateSimpleScreenViewModel] reducer with action \(action) produced state: \(state)") - } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileModels.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileModels.swift index 62e4c895c..e57ce601e 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileModels.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileModels.swift @@ -41,11 +41,6 @@ extension TemplateUserProfilePresence: Identifiable, CaseIterable { // MARK: View model -enum TemplateUserProfileStateAction { - case viewAction(TemplateUserProfileViewAction) - case updatePresence(TemplateUserProfilePresence) -} - enum TemplateUserProfileViewModelResult { case cancel case done diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileViewModel.swift index e476b467e..a6dfd50c1 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/TemplateUserProfileViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias TemplateUserProfileViewModelType = StateStoreViewModel @available(iOS 14, *) class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUserProfileViewModelProtocol { @@ -54,49 +54,28 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs count: 0 ) } - + private func setupPresenceObserving() { - let presenceUpdatePublisher = templateUserProfileService.presenceSubject - .map(TemplateUserProfileStateAction.updatePresence) - .eraseToAnyPublisher() - dispatch(actionPublisher: presenceUpdatePublisher) + templateUserProfileService + .presenceSubject + .sink(receiveValue: { [weak self] presence in + self?.state.presence = presence + }) + .store(in: &cancellables) } - + // MARK: - Public override func process(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: - cancel() + completion?(.cancel) case .done: - done() - case .incrementCount, .decrementCount: - dispatch(action: .viewAction(viewAction)) + completion?(.done) + case .incrementCount: + state.count += 1 + case .decrementCount: + state.count -= 1 } } - - override class func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { - switch action { - case .updatePresence(let presence): - state.presence = presence - case .viewAction(let viewAction): - switch viewAction { - case .incrementCount: - state.count += 1 - case .decrementCount: - state.count -= 1 - case .cancel, .done: - break - } - } - UILog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") - } - - private func done() { - completion?(.done) - } - - private func cancel() { - completion?(.cancel) - } } diff --git a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatModels.swift b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatModels.swift index de30950e2..e9aa09141 100644 --- a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatModels.swift +++ b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatModels.swift @@ -83,13 +83,6 @@ enum TemplateRoomChatRoomInitializationStatus { case failedToInitialize } -/// Actions to be performed on the `ViewModel` State -enum TemplateRoomChatStateAction { - case updateRoomInitializationStatus(TemplateRoomChatRoomInitializationStatus) - case updateBubbles([TemplateRoomChatBubble]) - case clearMessageInput -} - /// Actions sent by the `ViewModel` to the `Coordinator` enum TemplateRoomChatViewModelAction { case done diff --git a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatViewModel.swift b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatViewModel.swift index 2be3c5818..0a6e2a0d2 100644 --- a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatViewModel.swift +++ b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/TemplateRoomChatViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias TemplateRoomChatViewModelType = StateStoreViewModel @available(iOS 14, *) @@ -48,21 +48,22 @@ class TemplateRoomChatViewModel: TemplateRoomChatViewModelType, TemplateRoomChat } private func setupRoomInitializationObserving() { - let initializationPublisher = templateRoomChatService + templateRoomChatService .roomInitializationStatus - .map(TemplateRoomChatStateAction.updateRoomInitializationStatus) - .eraseToAnyPublisher() - - dispatch(actionPublisher: initializationPublisher) + .sink { [weak self] status in + self?.state.roomInitializationStatus = status + } + .store(in: &cancellables) } - + private func setupMessageObserving() { - let messageActionPublisher = templateRoomChatService + templateRoomChatService .chatMessagesSubject .map(Self.makeBubbles(messages:)) - .map(TemplateRoomChatStateAction.updateBubbles) - .eraseToAnyPublisher() - dispatch(actionPublisher: messageActionPublisher) + .sink { [weak self] bubbles in + self?.state.bubbles = bubbles + } + .store(in: &cancellables) } private static func defaultState(templateRoomChatService: TemplateRoomChatServiceProtocol) -> TemplateRoomChatViewState { @@ -117,27 +118,10 @@ class TemplateRoomChatViewModel: TemplateRoomChatViewModelType, TemplateRoomChat override func process(viewAction: TemplateRoomChatViewAction) { switch viewAction { case .done: - done() + callback?(.done) case .sendMessage: templateRoomChatService.send(textMessage: state.bindings.messageInput) - dispatch(action: .clearMessageInput) - } - } - - override class func reducer(state: inout TemplateRoomChatViewState, action: TemplateRoomChatStateAction) { - switch action { - case .updateRoomInitializationStatus(let status): - state.roomInitializationStatus = status - case .clearMessageInput: state.bindings.messageInput = "" - case .updateBubbles(let bubbles): - state.bubbles = bubbles } } - - // MARK: - Private - - private func done() { - callback?(.done) - } } diff --git a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListModels.swift b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListModels.swift index 6adb43c0c..57452cc1c 100644 --- a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListModels.swift +++ b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListModels.swift @@ -32,11 +32,6 @@ enum TemplateRoomListCoordinatorAction { // MARK: - View model -/// Actions to be performed on the `ViewModel` State -enum TemplateRoomListStateAction { - case updateRooms([TemplateRoomListRoom]) -} - /// Actions sent by the`ViewModel` to the `Coordinator`. enum TemplateRoomListViewModelAction { case didSelectRoom(String) diff --git a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListViewModel.swift b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListViewModel.swift index 338146605..4c906c237 100644 --- a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListViewModel.swift +++ b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomList/TemplateRoomListViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias TemplateRoomListViewModelType = StateStoreViewModel @available(iOS 14.0, *) class TemplateRoomListViewModel: TemplateRoomListViewModelType, TemplateRoomListViewModelProtocol { @@ -47,10 +47,12 @@ class TemplateRoomListViewModel: TemplateRoomListViewModelType, TemplateRoomList } private func startObservingRooms() { - let roomsUpdatePublisher = templateRoomListService.roomsSubject - .map(TemplateRoomListStateAction.updateRooms) - .eraseToAnyPublisher() - dispatch(actionPublisher: roomsUpdatePublisher) + templateRoomListService + .roomsSubject + .sink { [weak self] rooms in + self?.state.rooms = rooms + } + .store(in: &cancellables) } // MARK: - Public @@ -64,13 +66,6 @@ class TemplateRoomListViewModel: TemplateRoomListViewModelType, TemplateRoomList } } - override class func reducer(state: inout TemplateRoomListViewState, action: TemplateRoomListStateAction) { - switch action { - case .updateRooms(let rooms): - state.rooms = rooms - } - } - // MARK: - Private private func done() {