From 3ef02af877ed13726f78ac4f012fcc65361bd9a6 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 2 Sep 2021 09:37:37 +0100 Subject: [PATCH 01/31] Add simple example with profile header, avatar and name. --- .../Common/Avatar/Model/Avatarable.swift | 34 +++++++++++++ .../Mock/TemplateMockUserService.swift | 28 +++++++++++ .../Model/TemplateUserProfileViewState.swift | 22 ++++++++ .../View/TemplateUserProfile.swift | 50 +++++++++++++++++++ .../TemplateUserProfileViewModel.swift | 34 +++++++++++++ .../ViewModel/TemplateUserServiceType.swift | 34 +++++++++++++ 6 files changed, 202 insertions(+) create mode 100644 RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift new file mode 100644 index 000000000..417e1e5ab --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift @@ -0,0 +1,34 @@ +// +// 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 + +/** + A protcol that any class or struct can conform to + so that it can easiy produce avatar data. + E.g. MXRoom, MxUser cna conform to this making it + easy to grab the avatar data for display. + */ +protocol Avatarable: AvatarInputType { } +extension Avatarable { + var avatarData: AvatarInput { + AvatarInput( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: displayName + ) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift new file mode 100644 index 000000000..4bbf64b49 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift @@ -0,0 +1,28 @@ +// +// 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 + +struct TemplateMockUserService: TemplateUserServiceType { + + static let example = TemplateMockUserService(userId: "123", displayName: "Alice", avatarUrl: "mx123@matrix.com", currentlyActive: true, lastActive: 123456) + + let userId: String + let displayName: String? + let avatarUrl: String? + let currentlyActive: Bool + let lastActive: UInt +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift new file mode 100644 index 000000000..b5b308242 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift @@ -0,0 +1,22 @@ +// +// 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 + +struct TemplateUserProfileViewState { + let avatar: AvatarInputType? + let displayName: String? +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift new file mode 100644 index 000000000..3094158f3 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift @@ -0,0 +1,50 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct TemplateUserProfile: View { + + @ObservedObject var viewModel: TemplateUserProfileViewModel + + var header: some View { + VStack { + if let avatar = viewModel.viewState.avatar { + HStack{ + Spacer() + AvatarImage(avatarData: avatar, size: .xxLarge) + Spacer() + } + } + Text(viewModel.viewState.displayName ?? "") + } + + } + var body: some View { + VectorForm { + header + } + } +} + +@available(iOS 14.0, *) +struct TemplateUserProfile_Previews: PreviewProvider { + static var previews: some View { + TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: TemplateMockUserService.example)) + .addDependency(MockAvatarService.example) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift new file mode 100644 index 000000000..cfc0564a7 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -0,0 +1,34 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +class TemplateUserProfileViewModel: ObservableObject { + + private let userService: TemplateUserServiceType + + @Published var viewState: TemplateUserProfileViewState + + private static func defaultState(userService: TemplateUserServiceType) -> TemplateUserProfileViewState { + return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName) + } + + init(userService: TemplateUserServiceType, initialState: TemplateUserProfileViewState? = nil) { + self.userService = userService + self.viewState = initialState ?? Self.defaultState(userService: userService) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift new file mode 100644 index 000000000..87fb0e242 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift @@ -0,0 +1,34 @@ +// +// 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 + +protocol TemplateUserServiceType: Avatarable { + var userId: String { get } + var displayName: String? { get } + var avatarUrl: String? { get } + var currentlyActive: Bool { get } + var lastActive: UInt { get } +} + +extension TemplateUserServiceType { + var mxContentUri: String? { + avatarUrl + } + var matrixItemId: String { + userId + } +} From d82ae75a0b9f608c7fc237d6c3c002b1a163b9fb Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 6 Sep 2021 17:12:31 +0100 Subject: [PATCH 02/31] Add Simple Template Example - Add a simple Template example that shows a user profile with avatar, displayName and presence. - ScreenCoordinator: closure based with less protocols and delegates. - Reducer: Reducer function that manages all state modifications. - SwiftUI View: Decomposes UI into appropriate sub components. - Uses Theme and Dependency Management Infrastructure --- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 + .../SwiftUI/VectorHostingController.swift | 9 +++ .../{ => Model}/Mock/MockAvatarInput.swift | 0 .../Service/MatrixSDK/MXAvatarService.swift | 14 ++-- .../Mock/MockAvatarService.swift | 0 .../RoomNotificationSettingsCoordinator.swift | 2 +- .../TemplateUserProfileCoordinator.swift | 75 ++++++++++++++++++ .../Model/TemplatePresence.swift | 42 ++++++++++ .../TemplateProfileStateAction.swift} | 11 +-- .../Model/TemplateUserProfileViewState.swift | 1 + .../MatrixSDK/MXTemplateUserService.swift | 76 +++++++++++++++++++ .../Mock/MockTemplateUserService.swift | 33 ++++++++ .../TemplateUserServiceType.swift | 6 +- .../View/TemplatePresenceView.swift | 59 ++++++++++++++ .../View/TemplateUserProfile.swift | 55 ++++++++++---- .../View/TemplateUserProfileHeader.swift | 52 +++++++++++++ .../TemplateUserProfileViewModel.swift | 29 ++++++- 18 files changed, 438 insertions(+), 31 deletions(-) rename RiotSwiftUI/Modules/Common/Avatar/{ => Model}/Mock/MockAvatarInput.swift (100%) rename Riot/Modules/Common/Avatar/AvatarService.swift => RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift (86%) rename RiotSwiftUI/Modules/Common/Avatar/{ => Service}/Mock/MockAvatarService.swift (100%) create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift rename RiotSwiftUI/Modules/Template/SimpleProfileExample/{Mock/TemplateMockUserService.swift => Model/TemplateProfileStateAction.swift} (62%) create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift rename RiotSwiftUI/Modules/Template/SimpleProfileExample/{ViewModel => Service}/TemplateUserServiceType.swift (87%) create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index d7cad47ca..94e56756b 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -63,6 +63,7 @@ "switch" = "Switch"; "more" = "More"; "less" = "Less"; +"done" = "Done"; // Call Bar "callbar_only_single_active" = "Tap to return to the call (%@)"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index d0ec69c4f..48c53d8b2 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1138,6 +1138,10 @@ internal enum VectorL10n { internal static var doNotAskAgain: String { return VectorL10n.tr("Vector", "do_not_ask_again") } + /// Done + internal static var done: String { + return VectorL10n.tr("Vector", "done") + } /// Element now supports end-to-end encryption but you need to log in again to enable it.\n\nYou can do it now or later from the application settings. internal static var e2eEnablingOnAppUpdate: String { return VectorL10n.tr("Vector", "e2e_enabling_on_app_update") diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 504504312..d4c4dd639 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -27,12 +27,21 @@ class VectorHostingController: UIHostingController { // MARK: Private private var theme: Theme + + init() { + self.theme = ThemeService.shared().theme + super.init(rootView: AnyView(EmptyView())) + } init(rootView: Content) where Content: View { self.theme = ThemeService.shared().theme super.init(rootView: AnyView(rootView.vectorContent())) } + func setRoot(view: V) { + rootView = AnyView(view) + } + required init?(coder aDecoder: NSCoder) { fatalError("VectorHostingViewController does not currently support init from nibs") } diff --git a/RiotSwiftUI/Modules/Common/Avatar/Mock/MockAvatarInput.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Mock/MockAvatarInput.swift similarity index 100% rename from RiotSwiftUI/Modules/Common/Avatar/Mock/MockAvatarInput.swift rename to RiotSwiftUI/Modules/Common/Avatar/Model/Mock/MockAvatarInput.swift diff --git a/Riot/Modules/Common/Avatar/AvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift similarity index 86% rename from Riot/Modules/Common/Avatar/AvatarService.swift rename to RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift index 1e73834e7..bfac9549c 100644 --- a/Riot/Modules/Common/Avatar/AvatarService.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift @@ -19,12 +19,12 @@ import MatrixSDK import Combine import DesignKit -enum AvatarServiceError: Error { +enum MXAvatarServiceError: Error { case pathNotfound case loadingImageFailed(Error?) } -class AvatarService: AvatarServiceType { +class MXAvatarService: AvatarServiceType { private enum Constants { static let mimeType = "image/jpeg" @@ -33,6 +33,10 @@ class AvatarService: AvatarServiceType { private let mediaManager: MXMediaManager + static func instantiate(mediaManager: MXMediaManager) -> AvatarServiceType { + return MXAvatarService(mediaManager: mediaManager) + } + init(mediaManager: MXMediaManager) { self.mediaManager = mediaManager } @@ -69,18 +73,18 @@ class AvatarService: AvatarServiceType { toFitViewSize: avatarSize.size, with: Constants.thumbnailMethod) { path in guard let path = path else { - promise(.failure(AvatarServiceError.pathNotfound)) + promise(.failure(MXAvatarServiceError.pathNotfound)) return } guard let image = MXMediaManager.loadThroughCache(withFilePath: path), let imageUp = Self.orientImageUp(image: image) else { - promise(.failure(AvatarServiceError.loadingImageFailed(nil))) + promise(.failure(MXAvatarServiceError.loadingImageFailed(nil))) return } promise(.success(imageUp)) } failure: { error in - promise(.failure(AvatarServiceError.loadingImageFailed(error))) + promise(.failure(MXAvatarServiceError.loadingImageFailed(error))) } } } diff --git a/RiotSwiftUI/Modules/Common/Avatar/Mock/MockAvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift similarity index 100% rename from RiotSwiftUI/Modules/Common/Avatar/Mock/MockAvatarService.swift rename to RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift index 786e732b1..be17cf926 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift @@ -64,7 +64,7 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin avatarData: avatarData, displayName: room.summary.displayname, roomEncrypted: room.summary.isEncrypted) - let avatarService: AvatarServiceType = AvatarService(mediaManager: room.mxSession.mediaManager) + let avatarService: AvatarServiceType = MXAvatarService(mediaManager: room.mxSession.mediaManager) let view = RoomNotificationSettings(viewModel: swiftUIViewModel, presentedModally: presentedModally) .addDependency(avatarService) let host = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift new file mode 100644 index 000000000..9d1f1d9ab --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -0,0 +1,75 @@ +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class TemplateUserProfileCoordinator: Coordinator { + + typealias Completion = () -> Void + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let templateUserProfileViewController: UIViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: Completion? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(session: MXSession) { + self.session = session + let hostViewController = VectorHostingController() + templateUserProfileViewController = UINavigationController(rootViewController: hostViewController) + let rootView = TemplateUserProfile.instantiate(session: session, completion: self.userProfileCompletion(result:)) + hostViewController.setRoot(view: rootView) + } + + @available(iOS 14.0, *) + func userProfileCompletion(result: TemplateUserProfile.Result) { + switch result { + case .cancel, .done: + completion?() + break + } + } + + // MARK: - Public methods + + func start() { + + } + + func toPresentable() -> UIViewController { + return self.templateUserProfileViewController + } +} + +@available(iOS 14.0, *) +extension TemplateUserProfile { + static func instantiate(session: MXSession, completion: @escaping TemplateUserProfile.Completion) -> some View { + let templateUserProfileViewModel = TemplateUserProfileViewModel(userService: MXTemplateUserService(session: session)) + let templateUserProfile = TemplateUserProfile(viewModel: templateUserProfileViewModel, completion: completion) + return templateUserProfile.addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager)) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift new file mode 100644 index 000000000..3ce14040f --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift @@ -0,0 +1,42 @@ +// +// 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 + +enum TemplatePresence { + case online + case idle + case offline +} + +extension TemplatePresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension TemplatePresence: CaseIterable { } + +extension TemplatePresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift similarity index 62% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift index 4bbf64b49..cacfe7c6f 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Mock/TemplateMockUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift @@ -16,13 +16,6 @@ import Foundation -struct TemplateMockUserService: TemplateUserServiceType { - - static let example = TemplateMockUserService(userId: "123", displayName: "Alice", avatarUrl: "mx123@matrix.com", currentlyActive: true, lastActive: 123456) - - let userId: String - let displayName: String? - let avatarUrl: String? - let currentlyActive: Bool - let lastActive: UInt +enum TemplateProfileStateAction { + case updatePresence(TemplatePresence) } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift index b5b308242..5ef626a0d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift @@ -19,4 +19,5 @@ import Foundation struct TemplateUserProfileViewState { let avatar: AvatarInputType? let displayName: String? + var presence: TemplatePresence = .offline } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift new file mode 100644 index 000000000..0aedaa277 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift @@ -0,0 +1,76 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MXTemplateUserService: TemplateUserServiceType { + + let session: MXSession + var listenerReference: Any! + @Published var presence: TemplatePresence = .offline + + init(session: MXSession) { + self.session = session + + let listenerReference = session.myUser.listen { [weak self] event in + guard let self = self, + let event = event, + case .presence = MXEventType(identifier: event.eventId) + else { return } + self.presence = TemplatePresence(mxPresence: self.session.myUser.presence) + } + self.listenerReference = listenerReference + } + + var userId: String { + return session.myUser.userId + } + + var displayName: String? { + session.myUser.displayname + } + + var avatarUrl: String? { + session.myUser.avatarUrl + } + + var presencePublisher: AnyPublisher { + $presence.eraseToAnyPublisher() + } + + deinit { + session.myUser.removeListener(listenerReference) + } +} + +fileprivate extension TemplatePresence { + + init(mxPresence: MXPresence) { + + switch mxPresence { + case MXPresenceOnline: + self = .online + case MXPresenceUnavailable: + self = .idle + case MXPresenceOffline, MXPresenceUnknown: + self = .offline + default: + self = .offline + } + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift new file mode 100644 index 000000000..4c9fd734f --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift @@ -0,0 +1,33 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockTemplateUserService: TemplateUserServiceType, ObservableObject { + + static let example = MockTemplateUserService() + @Published var presence: TemplatePresence = .online + var presencePublisher: AnyPublisher { + $presence.eraseToAnyPublisher() + } + let userId: String = "123" + let displayName: String? = "Alice" + let avatarUrl: String? = "mx123@matrix.com" + let currentlyActive: Bool = true + let lastActive: UInt = 1630596918513 +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift similarity index 87% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift index 87fb0e242..e9b88ffba 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserServiceType.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift @@ -15,15 +15,17 @@ // import Foundation +import Combine +@available(iOS 14.0, *) protocol TemplateUserServiceType: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } - var currentlyActive: Bool { get } - var lastActive: UInt { get } + var presencePublisher: AnyPublisher { get } } +@available(iOS 14.0, *) extension TemplateUserServiceType { var mxContentUri: String? { avatarUrl diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift new file mode 100644 index 000000000..18b45609d --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift @@ -0,0 +1,59 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct TemplatePresenceView: View { + + let presense: TemplatePresence + + var foregroundColor: Color { + switch presense { + case .online: + return .green + case .idle: + return .orange + case .offline: + return .gray + } + } + + var body: some View { + HStack { + Image(systemName: "circle.fill") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(foregroundColor) + Text(presense.title) + .font(.subheadline) + } + .foregroundColor(foregroundColor) + .padding(0) + } +} + +@available(iOS 14.0, *) +struct TemplatePresenceView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment:.leading){ + Text("Presence") + ForEach(TemplatePresence.allCases) { presence in + TemplatePresenceView(presense: presence) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift index 3094158f3..057bf185c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift @@ -19,32 +19,61 @@ import SwiftUI @available(iOS 14.0, *) struct TemplateUserProfile: View { + enum Result { + case cancel + case done + } + + typealias Completion = (Result) -> Void + + @Environment(\.theme) var theme: ThemeSwiftUI @ObservedObject var viewModel: TemplateUserProfileViewModel - var header: some View { + var completion: Completion + + var leftButton: some View { + Button(VectorL10n.cancel) { + completion(.cancel) + } + } + + var rightButton: some View { + Button(VectorL10n.done) { + completion(.done) + } + } + + var body: some View { VStack { - if let avatar = viewModel.viewState.avatar { - HStack{ + TemplateUserProfileHeader( + avatar: viewModel.viewState.avatar, + displayName: viewModel.viewState.displayName, + presence: viewModel.viewState.presence + ) + Divider() + VStack{ + HStack(alignment: .center){ Spacer() - AvatarImage(avatarData: avatar, size: .xxLarge) + Text("More great user content!") + .font(theme.fonts.title2) + .foregroundColor(theme.colors.secondaryContent) Spacer() } } - Text(viewModel.viewState.displayName ?? "") - } - - } - var body: some View { - VectorForm { - header + .frame(maxHeight: .infinity) } + .frame(maxHeight: .infinity) + .navigationTitle(viewModel.viewState.displayName ?? "") + .navigationBarItems(leading: leftButton, trailing: rightButton) } } @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: TemplateMockUserService.example)) - .addDependency(MockAvatarService.example) + TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserService.example)) { _ in + + } + .addDependency(MockAvatarService.example) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift new file mode 100644 index 000000000..c13a15a7b --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift @@ -0,0 +1,52 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct TemplateUserProfileHeader: View { + + @Environment(\.theme) var theme: ThemeSwiftUI + let avatar: AvatarInputType? + let displayName: String? + let presence: TemplatePresence + + var body: some View { + VStack { + if let avatar = avatar { + HStack{ + Spacer() + AvatarImage(avatarData: avatar, size: .xxLarge) + Spacer() + } + .padding(.vertical) + } + VStack(spacing: 8){ + Text(displayName ?? "") + .font(theme.fonts.title3) + TemplatePresenceView(presense: presence) + } + } + } +} + +@available(iOS 14.0, *) +struct TemplateUserProfileHeader_Previews: PreviewProvider { + static var previews: some View { + TemplateUserProfileHeader(avatar: MockAvatarInput.example, displayName: "Alice", presence: .online) + .addDependency(MockAvatarService.example) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift index cfc0564a7..5a98afa80 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -15,13 +15,15 @@ // import SwiftUI +import Combine @available(iOS 14.0, *) class TemplateUserProfileViewModel: ObservableObject { private let userService: TemplateUserServiceType + private var cancellables = Set() - @Published var viewState: TemplateUserProfileViewState + @Published private(set) var viewState: TemplateUserProfileViewState private static func defaultState(userService: TemplateUserServiceType) -> TemplateUserProfileViewState { return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName) @@ -30,5 +32,30 @@ class TemplateUserProfileViewModel: ObservableObject { init(userService: TemplateUserServiceType, initialState: TemplateUserProfileViewState? = nil) { self.userService = userService self.viewState = initialState ?? Self.defaultState(userService: userService) + + userService.presencePublisher + .map(TemplateProfileStateAction.updatePresence) + .receive(on: DispatchQueue.main) + .sink(receiveValue: self.dispatch(action:)) + .store(in: &cancellables) + } + + /** + Send state actions to mutate the state. + */ + private func dispatch(action: TemplateProfileStateAction) { + var newState = self.viewState + reducer(state: &newState, action: action) + self.viewState = newState + } + + /** + A redux style reducer, all modifications to state happen here. Recieves a state and a state action and produces a new state. + */ + private func reducer(state: inout TemplateUserProfileViewState, action: TemplateProfileStateAction) { + switch action { + case .updatePresence(let presence): + state.presence = presence + } } } From 27200506468ea108cb8061ba03981d7727bfc4d2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 7 Sep 2021 16:57:08 +0100 Subject: [PATCH 03/31] Add ViewActions and ViewModelActions for Coordinator - also add Marks --- .../TemplateUserProfileCoordinator.swift | 41 +++++++------------ .../Model/TemplateProfileViewAction.swift | 22 ++++++++++ .../TemplateUserProfileViewModelResult.swift | 22 ++++++++++ .../MatrixSDK/MXTemplateUserService.swift | 41 +++++++++++-------- .../Mock/MockTemplateUserService.swift | 2 +- ...wift => TemplateUserServiceProtocol.swift} | 4 +- .../View/TemplateUserProfile.swift | 21 +++------- .../TemplateUserProfileViewModel.swift | 38 ++++++++++++++--- ...TemplateUserProfileViewModelProtocol.swift | 21 ++++++++++ 9 files changed, 144 insertions(+), 68 deletions(-) create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/{TemplateUserServiceType.swift => TemplateUserServiceProtocol.swift} (91%) create mode 100644 RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 9d1f1d9ab..43a155c8d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -20,56 +20,45 @@ import SwiftUI final class TemplateUserProfileCoordinator: Coordinator { - typealias Completion = () -> Void // MARK: - Properties // MARK: Private private let session: MXSession private let templateUserProfileViewController: UIViewController + private var templateUserProfileViewModel: TemplateUserProfileViewModelProtocol // MARK: Public // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: Completion? + var completion: (() -> Void)? // MARK: - Setup @available(iOS 14.0, *) init(session: MXSession) { self.session = session - let hostViewController = VectorHostingController() - templateUserProfileViewController = UINavigationController(rootViewController: hostViewController) - let rootView = TemplateUserProfile.instantiate(session: session, completion: self.userProfileCompletion(result:)) - hostViewController.setRoot(view: rootView) - } - - @available(iOS 14.0, *) - func userProfileCompletion(result: TemplateUserProfile.Result) { - switch result { - case .cancel, .done: - completion?() - break - } + let viewModel = TemplateUserProfileViewModel(userService: MXTemplateUserService(session: session)) + let view = TemplateUserProfile(viewModel: viewModel) + .addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager)) + templateUserProfileViewModel = viewModel + templateUserProfileViewController = VectorHostingController(rootView: view) } // MARK: - Public methods - func start() { - + templateUserProfileViewModel.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel, .done: + self.completion?() + break + } + } } func toPresentable() -> UIViewController { return self.templateUserProfileViewController } } - -@available(iOS 14.0, *) -extension TemplateUserProfile { - static func instantiate(session: MXSession, completion: @escaping TemplateUserProfile.Completion) -> some View { - let templateUserProfileViewModel = TemplateUserProfileViewModel(userService: MXTemplateUserService(session: session)) - let templateUserProfile = TemplateUserProfile(viewModel: templateUserProfileViewModel, completion: completion) - return templateUserProfile.addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager)) - } -} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift new file mode 100644 index 000000000..84a732dae --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum TemplateProfileViewAction { + case cancel + case done +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift new file mode 100644 index 000000000..2c2965f5b --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum TemplateUserProfileViewModelResult { + case cancel + case done +} diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift index 0aedaa277..fc2f3a7fe 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift @@ -18,24 +18,17 @@ import Foundation import Combine @available(iOS 14.0, *) -class MXTemplateUserService: TemplateUserServiceType { +class MXTemplateUserService: TemplateUserServiceProtocol { - let session: MXSession - var listenerReference: Any! - @Published var presence: TemplatePresence = .offline + // MARK: - Properties - init(session: MXSession) { - self.session = session - - let listenerReference = session.myUser.listen { [weak self] event in - guard let self = self, - let event = event, - case .presence = MXEventType(identifier: event.eventId) - else { return } - self.presence = TemplatePresence(mxPresence: self.session.myUser.presence) - } - self.listenerReference = listenerReference - } + // MARK: Private + + private let session: MXSession + private var listenerReference: Any! + @Published private var presence: TemplatePresence = .offline + + // MARK: Public var userId: String { return session.myUser.userId @@ -53,6 +46,21 @@ class MXTemplateUserService: TemplateUserServiceType { $presence.eraseToAnyPublisher() } + // MARK: - Setup + + init(session: MXSession) { + self.session = session + + let listenerReference = session.myUser.listen { [weak self] event in + guard let self = self, + let event = event, + case .presence = MXEventType(identifier: event.eventId) + else { return } + self.presence = TemplatePresence(mxPresence: self.session.myUser.presence) + } + self.listenerReference = listenerReference + } + deinit { session.myUser.removeListener(listenerReference) } @@ -61,7 +69,6 @@ class MXTemplateUserService: TemplateUserServiceType { fileprivate extension TemplatePresence { init(mxPresence: MXPresence) { - switch mxPresence { case MXPresenceOnline: self = .online diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift index 4c9fd734f..2f433f924 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift @@ -18,7 +18,7 @@ import Foundation import Combine @available(iOS 14.0, *) -class MockTemplateUserService: TemplateUserServiceType, ObservableObject { +class MockTemplateUserService: TemplateUserServiceProtocol, ObservableObject { static let example = MockTemplateUserService() @Published var presence: TemplatePresence = .online diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift similarity index 91% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift index e9b88ffba..06d3a3f1f 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceType.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift @@ -18,7 +18,7 @@ import Foundation import Combine @available(iOS 14.0, *) -protocol TemplateUserServiceType: Avatarable { +protocol TemplateUserServiceProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } @@ -26,7 +26,7 @@ protocol TemplateUserServiceType: Avatarable { } @available(iOS 14.0, *) -extension TemplateUserServiceType { +extension TemplateUserServiceProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift index 057bf185c..29c80798e 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift @@ -18,28 +18,19 @@ import SwiftUI @available(iOS 14.0, *) struct TemplateUserProfile: View { - - enum Result { - case cancel - case done - } - - typealias Completion = (Result) -> Void - + @Environment(\.theme) var theme: ThemeSwiftUI @ObservedObject var viewModel: TemplateUserProfileViewModel - - var completion: Completion - + var leftButton: some View { Button(VectorL10n.cancel) { - completion(.cancel) + viewModel.proccess(viewAction: .cancel) } } var rightButton: some View { Button(VectorL10n.done) { - completion(.done) + viewModel.proccess(viewAction: .cancel) } } @@ -71,9 +62,7 @@ struct TemplateUserProfile: View { @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserService.example)) { _ in - - } + TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserService.example)) .addDependency(MockAvatarService.example) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 5a98afa80..136717c89 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -16,20 +16,27 @@ import SwiftUI import Combine - + @available(iOS 14.0, *) -class TemplateUserProfileViewModel: ObservableObject { +class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewModelProtocol { - private let userService: TemplateUserServiceType - private var cancellables = Set() + // MARK: - Properties + // MARK: Private + private let userService: TemplateUserServiceProtocol + private var cancellables = Set() + + // MARK: Public @Published private(set) var viewState: TemplateUserProfileViewState - private static func defaultState(userService: TemplateUserServiceType) -> TemplateUserProfileViewState { + var completion: ((TemplateUserProfileViewModelResult) -> Void)? + + private static func defaultState(userService: TemplateUserServiceProtocol) -> TemplateUserProfileViewState { return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName) } - init(userService: TemplateUserServiceType, initialState: TemplateUserProfileViewState? = nil) { + // MARK: - Setup + init(userService: TemplateUserServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { self.userService = userService self.viewState = initialState ?? Self.defaultState(userService: userService) @@ -40,6 +47,17 @@ class TemplateUserProfileViewModel: ObservableObject { .store(in: &cancellables) } + // MARK: - Public methods + func proccess(viewAction: TemplateProfileViewAction) { + switch viewAction { + case .cancel: + self.cancel() + case .done: + self.done() + } + } + + // MARK: - Private methods /** Send state actions to mutate the state. */ @@ -58,4 +76,12 @@ class TemplateUserProfileViewModel: ObservableObject { state.presence = presence } } + + private func done() { + completion?(.done) + } + + private func cancel() { + completion?(.cancel) + } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift new file mode 100644 index 000000000..4f038ae32 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift @@ -0,0 +1,21 @@ +// +// 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 + +protocol TemplateUserProfileViewModelProtocol { + var completion: ((TemplateUserProfileViewModelResult) -> Void)? { get set } +} From bcc3b23b1c4530c6be97112d1e3c5fb7ed3e500b Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 7 Sep 2021 22:49:11 +0100 Subject: [PATCH 04/31] Remove unneeded protocol and methods on VectortHostingController --- .../Modules/Common/SwiftUI/VectorHostingController.swift | 9 --------- .../Service/Mock/MockTemplateUserService.swift | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index d4c4dd639..504504312 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -27,21 +27,12 @@ class VectorHostingController: UIHostingController { // MARK: Private private var theme: Theme - - init() { - self.theme = ThemeService.shared().theme - super.init(rootView: AnyView(EmptyView())) - } init(rootView: Content) where Content: View { self.theme = ThemeService.shared().theme super.init(rootView: AnyView(rootView.vectorContent())) } - func setRoot(view: V) { - rootView = AnyView(view) - } - required init?(coder aDecoder: NSCoder) { fatalError("VectorHostingViewController does not currently support init from nibs") } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift index 2f433f924..99065010a 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift @@ -18,7 +18,7 @@ import Foundation import Combine @available(iOS 14.0, *) -class MockTemplateUserService: TemplateUserServiceProtocol, ObservableObject { +class MockTemplateUserService: TemplateUserServiceProtocol { static let example = MockTemplateUserService() @Published var presence: TemplatePresence = .online From 72cfb688e571ae91b913b162b5cded6e0d8c38fc Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 7 Sep 2021 23:02:26 +0100 Subject: [PATCH 05/31] Make naming of symbols consistent --- .../TemplateUserProfileCoordinator.swift | 2 +- ...nce.swift => TemplateUserProfilePresence.swift} | 8 ++++---- ....swift => TemplateUserProfileStateAction.swift} | 4 ++-- ...n.swift => TemplateUserProfileViewAction.swift} | 2 +- .../Model/TemplateUserProfileViewState.swift | 2 +- ...ce.swift => MXTemplateUserProfileService.swift} | 10 +++++----- ....swift => MockTemplateUserProfileService.swift} | 8 ++++---- ...ft => TemplateUserProfileServiceProtocol.swift} | 6 +++--- .../View/TemplateUserProfile.swift | 2 +- .../View/TemplateUserProfileHeader.swift | 4 ++-- ...swift => TemplateUserProfilePresenceView.swift} | 8 ++++---- .../ViewModel/TemplateUserProfileViewModel.swift | 14 +++++++------- 12 files changed, 35 insertions(+), 35 deletions(-) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/{TemplatePresence.swift => TemplateUserProfilePresence.swift} (84%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/{TemplateProfileStateAction.swift => TemplateUserProfileStateAction.swift} (87%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/{TemplateProfileViewAction.swift => TemplateUserProfileViewAction.swift} (94%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/{MXTemplateUserService.swift => MXTemplateUserProfileService.swift} (83%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/{MockTemplateUserService.swift => MockTemplateUserProfileService.swift} (76%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/{TemplateUserServiceProtocol.swift => TemplateUserProfileServiceProtocol.swift} (82%) rename RiotSwiftUI/Modules/Template/SimpleProfileExample/View/{TemplatePresenceView.swift => TemplateUserProfilePresenceView.swift} (86%) diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 43a155c8d..3b3674967 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -39,7 +39,7 @@ final class TemplateUserProfileCoordinator: Coordinator { @available(iOS 14.0, *) init(session: MXSession) { self.session = session - let viewModel = TemplateUserProfileViewModel(userService: MXTemplateUserService(session: session)) + let viewModel = TemplateUserProfileViewModel(userService: MXTemplateUserProfileService(session: session)) let view = TemplateUserProfile(viewModel: viewModel) .addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager)) templateUserProfileViewModel = viewModel diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfilePresence.swift similarity index 84% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfilePresence.swift index 3ce14040f..c2d61aaea 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplatePresence.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfilePresence.swift @@ -16,13 +16,13 @@ import Foundation -enum TemplatePresence { +enum TemplateUserProfilePresence { case online case idle case offline } -extension TemplatePresence { +extension TemplateUserProfilePresence { var title: String { switch self { case .online: @@ -35,8 +35,8 @@ extension TemplatePresence { } } -extension TemplatePresence: CaseIterable { } +extension TemplateUserProfilePresence: CaseIterable { } -extension TemplatePresence: Identifiable { +extension TemplateUserProfilePresence: Identifiable { var id: Self { self } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileStateAction.swift similarity index 87% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileStateAction.swift index cacfe7c6f..e36cf35ce 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileStateAction.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileStateAction.swift @@ -16,6 +16,6 @@ import Foundation -enum TemplateProfileStateAction { - case updatePresence(TemplatePresence) +enum TemplateUserProfileStateAction { + case updatePresence(TemplateUserProfilePresence) } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewAction.swift similarity index 94% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewAction.swift index 84a732dae..372e24d30 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateProfileViewAction.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewAction.swift @@ -16,7 +16,7 @@ import Foundation -enum TemplateProfileViewAction { +enum TemplateUserProfileViewAction { case cancel case done } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift index 5ef626a0d..75b13eed9 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift @@ -19,5 +19,5 @@ import Foundation struct TemplateUserProfileViewState { let avatar: AvatarInputType? let displayName: String? - var presence: TemplatePresence = .offline + var presence: TemplateUserProfilePresence = .offline } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift similarity index 83% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift index fc2f3a7fe..889c5824d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift @@ -18,7 +18,7 @@ import Foundation import Combine @available(iOS 14.0, *) -class MXTemplateUserService: TemplateUserServiceProtocol { +class MXTemplateUserProfileService: TemplateUserProfileServiceProtocol { // MARK: - Properties @@ -26,7 +26,7 @@ class MXTemplateUserService: TemplateUserServiceProtocol { private let session: MXSession private var listenerReference: Any! - @Published private var presence: TemplatePresence = .offline + @Published private var presence: TemplateUserProfilePresence = .offline // MARK: Public @@ -42,7 +42,7 @@ class MXTemplateUserService: TemplateUserServiceProtocol { session.myUser.avatarUrl } - var presencePublisher: AnyPublisher { + var presencePublisher: AnyPublisher { $presence.eraseToAnyPublisher() } @@ -56,7 +56,7 @@ class MXTemplateUserService: TemplateUserServiceProtocol { let event = event, case .presence = MXEventType(identifier: event.eventId) else { return } - self.presence = TemplatePresence(mxPresence: self.session.myUser.presence) + self.presence = TemplateUserProfilePresence(mxPresence: self.session.myUser.presence) } self.listenerReference = listenerReference } @@ -66,7 +66,7 @@ class MXTemplateUserService: TemplateUserServiceProtocol { } } -fileprivate extension TemplatePresence { +fileprivate extension TemplateUserProfilePresence { init(mxPresence: MXPresence) { switch mxPresence { diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserProfileService.swift similarity index 76% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 99065010a..e7bd2a7e7 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -18,11 +18,11 @@ import Foundation import Combine @available(iOS 14.0, *) -class MockTemplateUserService: TemplateUserServiceProtocol { +class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { - static let example = MockTemplateUserService() - @Published var presence: TemplatePresence = .online - var presencePublisher: AnyPublisher { + static let example = MockTemplateUserProfileService() + @Published var presence: TemplateUserProfilePresence = .online + var presencePublisher: AnyPublisher { $presence.eraseToAnyPublisher() } let userId: String = "123" diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserProfileServiceProtocol.swift similarity index 82% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserProfileServiceProtocol.swift index 06d3a3f1f..581d7cc37 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserProfileServiceProtocol.swift @@ -18,15 +18,15 @@ import Foundation import Combine @available(iOS 14.0, *) -protocol TemplateUserServiceProtocol: Avatarable { +protocol TemplateUserProfileServiceProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } - var presencePublisher: AnyPublisher { get } + var presencePublisher: AnyPublisher { get } } @available(iOS 14.0, *) -extension TemplateUserServiceProtocol { +extension TemplateUserProfileServiceProtocol { var mxContentUri: String? { avatarUrl } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift index 29c80798e..d2d8ce7b0 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift @@ -62,7 +62,7 @@ struct TemplateUserProfile: View { @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserService.example)) + TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserProfileService.example)) .addDependency(MockAvatarService.example) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift index c13a15a7b..975010f61 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift @@ -22,7 +22,7 @@ struct TemplateUserProfileHeader: View { @Environment(\.theme) var theme: ThemeSwiftUI let avatar: AvatarInputType? let displayName: String? - let presence: TemplatePresence + let presence: TemplateUserProfilePresence var body: some View { VStack { @@ -37,7 +37,7 @@ struct TemplateUserProfileHeader: View { VStack(spacing: 8){ Text(displayName ?? "") .font(theme.fonts.title3) - TemplatePresenceView(presense: presence) + TemplateUserProfilePresenceView(presense: presence) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfilePresenceView.swift similarity index 86% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift rename to RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfilePresenceView.swift index 18b45609d..b2fa46a84 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplatePresenceView.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfilePresenceView.swift @@ -17,9 +17,9 @@ import SwiftUI @available(iOS 14.0, *) -struct TemplatePresenceView: View { +struct TemplateUserProfilePresenceView: View { - let presense: TemplatePresence + let presense: TemplateUserProfilePresence var foregroundColor: Color { switch presense { @@ -51,8 +51,8 @@ struct TemplatePresenceView_Previews: PreviewProvider { static var previews: some View { VStack(alignment:.leading){ Text("Presence") - ForEach(TemplatePresence.allCases) { presence in - TemplatePresenceView(presense: presence) + ForEach(TemplateUserProfilePresence.allCases) { presence in + TemplateUserProfilePresenceView(presense: presence) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 136717c89..d5091d7f5 100644 --- a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -23,7 +23,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod // MARK: - Properties // MARK: Private - private let userService: TemplateUserServiceProtocol + private let userService: TemplateUserProfileServiceProtocol private var cancellables = Set() // MARK: Public @@ -31,24 +31,24 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod var completion: ((TemplateUserProfileViewModelResult) -> Void)? - private static func defaultState(userService: TemplateUserServiceProtocol) -> TemplateUserProfileViewState { + private static func defaultState(userService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName) } // MARK: - Setup - init(userService: TemplateUserServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { + init(userService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { self.userService = userService self.viewState = initialState ?? Self.defaultState(userService: userService) userService.presencePublisher - .map(TemplateProfileStateAction.updatePresence) + .map(TemplateUserProfileStateAction.updatePresence) .receive(on: DispatchQueue.main) .sink(receiveValue: self.dispatch(action:)) .store(in: &cancellables) } // MARK: - Public methods - func proccess(viewAction: TemplateProfileViewAction) { + func proccess(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: self.cancel() @@ -61,7 +61,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod /** Send state actions to mutate the state. */ - private func dispatch(action: TemplateProfileStateAction) { + private func dispatch(action: TemplateUserProfileStateAction) { var newState = self.viewState reducer(state: &newState, action: action) self.viewState = newState @@ -70,7 +70,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod /** A redux style reducer, all modifications to state happen here. Recieves a state and a state action and produces a new state. */ - private func reducer(state: inout TemplateUserProfileViewState, action: TemplateProfileStateAction) { + private func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { switch action { case .updatePresence(let presence): state.presence = presence From 8259b82fb771721809020a77f355d9f1de7dbdcd Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 7 Sep 2021 23:04:52 +0100 Subject: [PATCH 06/31] Improve folder name --- .../Coordinator/TemplateUserProfileCoordinator.swift | 0 .../Model/TemplateUserProfilePresence.swift | 0 .../Model/TemplateUserProfileStateAction.swift | 0 .../Model/TemplateUserProfileViewAction.swift | 0 .../Model/TemplateUserProfileViewModelResult.swift | 0 .../Model/TemplateUserProfileViewState.swift | 0 .../Service/MatrixSDK/MXTemplateUserProfileService.swift | 0 .../Service/Mock/MockTemplateUserProfileService.swift | 0 .../Service/TemplateUserProfileServiceProtocol.swift | 0 .../View/TemplateUserProfile.swift | 0 .../View/TemplateUserProfileHeader.swift | 0 .../View/TemplateUserProfilePresenceView.swift | 0 .../ViewModel/TemplateUserProfileViewModel.swift | 0 .../ViewModel/TemplateUserProfileViewModelProtocol.swift | 0 14 files changed, 0 insertions(+), 0 deletions(-) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Coordinator/TemplateUserProfileCoordinator.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Model/TemplateUserProfilePresence.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Model/TemplateUserProfileStateAction.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Model/TemplateUserProfileViewAction.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Model/TemplateUserProfileViewModelResult.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Model/TemplateUserProfileViewState.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Service/MatrixSDK/MXTemplateUserProfileService.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Service/Mock/MockTemplateUserProfileService.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/Service/TemplateUserProfileServiceProtocol.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/View/TemplateUserProfile.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/View/TemplateUserProfileHeader.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/View/TemplateUserProfilePresenceView.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/ViewModel/TemplateUserProfileViewModel.swift (100%) rename RiotSwiftUI/Modules/Template/{SimpleProfileExample => SimpleUserProfileExample}/ViewModel/TemplateUserProfileViewModelProtocol.swift (100%) diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Coordinator/TemplateUserProfileCoordinator.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfilePresence.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfilePresence.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfilePresence.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfilePresence.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileStateAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileStateAction.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewAction.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewModelResult.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewModelResult.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewModelResult.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Model/TemplateUserProfileViewState.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/Mock/MockTemplateUserProfileService.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserProfileServiceProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/Service/TemplateUserProfileServiceProtocol.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfile.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfileHeader.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/View/TemplateUserProfilePresenceView.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModel.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift diff --git a/RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift similarity index 100% rename from RiotSwiftUI/Modules/Template/SimpleProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift From c890c831c56471db68760783f0c8db8a82345133 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 00:00:34 +0100 Subject: [PATCH 07/31] Correct preview name and create template script --- .../TemplateUserProfilePresenceView.swift | 2 +- Tools/Templates/createSwiftUISingleScreen.sh | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100755 Tools/Templates/createSwiftUISingleScreen.sh diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift index b2fa46a84..fa76efdd6 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift @@ -47,7 +47,7 @@ struct TemplateUserProfilePresenceView: View { } @available(iOS 14.0, *) -struct TemplatePresenceView_Previews: PreviewProvider { +struct TemplateUserProfilePresenceView_Previews: PreviewProvider { static var previews: some View { VStack(alignment:.leading){ Text("Presence") diff --git a/Tools/Templates/createSwiftUISingleScreen.sh b/Tools/Templates/createSwiftUISingleScreen.sh new file mode 100755 index 000000000..52729ead1 --- /dev/null +++ b/Tools/Templates/createSwiftUISingleScreen.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +if [ ! $# -eq 2 ]; then + echo "Usage: ./createSwiftUISingleScreen.sh Folder MyScreenName" + exit 1 +fi + +MODULE_DIR="../../RiotSwiftUI/Modules" +OUTPUT_DIR=$MODULE_DIR/$1 +SCREEN_NAME=$2 +SCREEN_VAR_NAME=`echo $SCREEN_NAME | awk '{ print tolower(substr($0, 1, 1)) substr($0, 2) }'` +TEMPLATE_DIR=$MODULE_DIR/Template/SimpleUserProfileExample/ +if [ -e $OUTPUT_DIR ]; then + echo "Error: Folder ${OUTPUT_DIR} already exists" + exit 1 +fi + +echo "Create folder ${OUTPUT_DIR}" + +mkdir -p $OUTPUT_DIR +cp -R $TEMPLATE_DIR $OUTPUT_DIR/ + +cd $OUTPUT_DIR +for file in $(find * -type f -print) +do + echo "Building ${file/TemplateUserProfile/$SCREEN_NAME}..." + perl -p -i -e "s/TemplateUserProfile/"$SCREEN_NAME"/g" $file + perl -p -i -e "s/templateUserProfile/"$SCREEN_VAR_NAME"/g" $file + + echo "// $ createScreen.sh $@" | cat - ${file} > /tmp/$$ && mv /tmp/$$ ${file} + echo '// File created from SimpleUserProfileExample' | cat - ${file} > /tmp/$$ && mv /tmp/$$ ${file} + + mv ${file} ${file/TemplateUserProfile/$SCREEN_NAME} +done From a35feeba2689fceb37d6d68a47154bbe2555cec3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 11:43:57 +0100 Subject: [PATCH 08/31] Fix ordering, add pragmas, fix visibility on a few functions, remove default param on state. --- .../TemplateUserProfileCoordinator.swift | 2 +- .../Model/TemplateUserProfileViewState.swift | 2 +- .../View/TemplateUserProfile.swift | 32 ++++++++++++------- .../View/TemplateUserProfileHeader.swift | 6 ++++ .../TemplateUserProfilePresenceView.swift | 29 ++++++++++------- .../TemplateUserProfileViewModel.swift | 12 +++---- 6 files changed, 52 insertions(+), 31 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 3b3674967..488de41ec 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -46,7 +46,7 @@ final class TemplateUserProfileCoordinator: Coordinator { templateUserProfileViewController = VectorHostingController(rootView: view) } - // MARK: - Public methods + // MARK: - Public func start() { templateUserProfileViewModel.completion = { [weak self] result in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift index 75b13eed9..8d8855262 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift @@ -19,5 +19,5 @@ import Foundation struct TemplateUserProfileViewState { let avatar: AvatarInputType? let displayName: String? - var presence: TemplateUserProfilePresence = .offline + var presence: TemplateUserProfilePresence } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index d2d8ce7b0..b3f059f2b 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -19,20 +19,12 @@ import SwiftUI @available(iOS 14.0, *) struct TemplateUserProfile: View { + // MARK: - Properties + + // MARK: Public + @Environment(\.theme) var theme: ThemeSwiftUI @ObservedObject var viewModel: TemplateUserProfileViewModel - - var leftButton: some View { - Button(VectorL10n.cancel) { - viewModel.proccess(viewAction: .cancel) - } - } - - var rightButton: some View { - Button(VectorL10n.done) { - viewModel.proccess(viewAction: .cancel) - } - } var body: some View { VStack { @@ -57,8 +49,24 @@ struct TemplateUserProfile: View { .navigationTitle(viewModel.viewState.displayName ?? "") .navigationBarItems(leading: leftButton, trailing: rightButton) } + + // MARK: Private + + private var leftButton: some View { + Button(VectorL10n.cancel) { + viewModel.proccess(viewAction: .cancel) + } + } + + private var rightButton: some View { + Button(VectorL10n.done) { + viewModel.proccess(viewAction: .cancel) + } + } } +// MARK: - Previews + @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift index 975010f61..72edf71fa 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift @@ -19,6 +19,10 @@ import SwiftUI @available(iOS 14.0, *) struct TemplateUserProfileHeader: View { + // MARK: - Properties + + // MARK: Public + @Environment(\.theme) var theme: ThemeSwiftUI let avatar: AvatarInputType? let displayName: String? @@ -43,6 +47,8 @@ struct TemplateUserProfileHeader: View { } } +// MARK: - Previews + @available(iOS 14.0, *) struct TemplateUserProfileHeader_Previews: PreviewProvider { static var previews: some View { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift index fa76efdd6..c5e825515 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift @@ -19,18 +19,10 @@ import SwiftUI @available(iOS 14.0, *) struct TemplateUserProfilePresenceView: View { - let presense: TemplateUserProfilePresence + // MARK: - Properties - var foregroundColor: Color { - switch presense { - case .online: - return .green - case .idle: - return .orange - case .offline: - return .gray - } - } + // MARK: Public + let presense: TemplateUserProfilePresence var body: some View { HStack { @@ -44,8 +36,23 @@ struct TemplateUserProfilePresenceView: View { .foregroundColor(foregroundColor) .padding(0) } + + // MARK: Private + + private var foregroundColor: Color { + switch presense { + case .online: + return .green + case .idle: + return .orange + case .offline: + return .gray + } + } } +// MARK: - Previews + @available(iOS 14.0, *) struct TemplateUserProfilePresenceView_Previews: PreviewProvider { static var previews: some View { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index d5091d7f5..ce00fe627 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -31,10 +31,6 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod var completion: ((TemplateUserProfileViewModelResult) -> Void)? - private static func defaultState(userService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { - return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName) - } - // MARK: - Setup init(userService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { self.userService = userService @@ -47,7 +43,11 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod .store(in: &cancellables) } - // MARK: - Public methods + private static func defaultState(userService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { + return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName, presence: .offline) + } + + // MARK: - Public func proccess(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: @@ -57,7 +57,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod } } - // MARK: - Private methods + // MARK: - Private /** Send state actions to mutate the state. */ From 0131654e47043c982d885ca6c29f5b962afc74f9 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 15:32:41 +0100 Subject: [PATCH 09/31] Add template unit tests and publisher utility. --- Riot/target.yml | 1 + .../Test/XCTestPublisherExtensions.swift | 55 +++++++++++++++++++ .../Mock/MockTemplateUserProfileService.swift | 7 ++- .../TemplateUserProfileViewModelTests.swift | 53 ++++++++++++++++++ RiotSwiftUI/target.yml | 1 + RiotTests/target.yml | 3 + 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift diff --git a/Riot/target.yml b/Riot/target.yml index 5693b5716..9428387f6 100644 --- a/Riot/target.yml +++ b/Riot/target.yml @@ -55,6 +55,7 @@ targets: # Riot will provide it's own LocaleProviderType so exclude. excludes: - "Common/Locale/LocaleProvider.swift" + - "**/Test/**" - path: ../Tools excludes: - "Logs" diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift new file mode 100644 index 000000000..68fb9ddc3 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -0,0 +1,55 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +/** + XCTest utility to wat for results from publishers, so that the output can be used for assertions. + */ +@available(iOS 14.0, *) +extension XCTestCase { + func xcAwait( + _ publisher: T, + timeout: TimeInterval = 10 + ) throws -> T.Output { + var result: Result? + let expectation = self.expectation(description: "Awaiting publisher") + + let cancellable = publisher.sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + result = .failure(error) + case .finished: + break + } + + expectation.fulfill() + }, + receiveValue: { value in + result = .success(value) + } + ) + waitForExpectations(timeout: timeout) + cancellable.cancel() + let unwrappedResult = try XCTUnwrap( + result, + "Awaited publisher did not produce any output" + ) + return try unwrappedResult.get() + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index e7bd2a7e7..1f6a8f570 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -21,7 +21,8 @@ import Combine class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { static let example = MockTemplateUserProfileService() - @Published var presence: TemplateUserProfilePresence = .online + static let initialPresenceState: TemplateUserProfilePresence = .offline + @Published var presence: TemplateUserProfilePresence = initialPresenceState var presencePublisher: AnyPublisher { $presence.eraseToAnyPublisher() } @@ -30,4 +31,8 @@ class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { let avatarUrl: String? = "mx123@matrix.com" let currentlyActive: Bool = true let lastActive: UInt = 1630596918513 + + func simulateUpdate(presence: TemplateUserProfilePresence) { + self.presence = presence + } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift new file mode 100644 index 000000000..cff2dba86 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import Riot + +@available(iOS 14.0, *) +class TemplateUserProfileViewModelTests: XCTestCase { + var service: MockTemplateUserProfileService! + var viewModel: TemplateUserProfileViewModel! + var cancellables = Set() + override func setUpWithError() throws { + service = MockTemplateUserProfileService() + viewModel = TemplateUserProfileViewModel(userService: service) + } + + func testInitialState() { + XCTAssertEqual(viewModel.viewState.displayName, MockTemplateUserProfileService.example.displayName) + XCTAssertEqual(viewModel.viewState.avatar?.mxContentUri, MockTemplateUserProfileService.example.avatarUrl) + XCTAssertEqual(viewModel.viewState.presence, MockTemplateUserProfileService.initialPresenceState) + } + + func testFirstPresenceRecieved() throws { + let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(1).first() + XCTAssertEqual(try xcAwait(presencePublisher), [MockTemplateUserProfileService.initialPresenceState]) + } + + func testPresenceUpdatesRecieved() throws { + let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let newPresenceValue1: TemplateUserProfilePresence = .online + let newPresenceValue2: TemplateUserProfilePresence = .idle + service.simulateUpdate(presence: newPresenceValue1) + service.simulateUpdate(presence: newPresenceValue2) + XCTAssertEqual(try xcAwait(presencePublisher), [MockTemplateUserProfileService.initialPresenceState, newPresenceValue1, newPresenceValue2]) + } + + +} diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index d8dc0c100..a685db564 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -31,6 +31,7 @@ targets: # Don't include service implementations and coordinator/bridges in target. - "**/MatrixSDK/**" - "**/Coordinator/**" + - "**/Test/**" - path: ../Riot/Generated/Strings.swift - path: ../Riot/Generated/Images.swift - path: ../Riot/Managers/Theme/ThemeIdentifier.swift diff --git a/RiotTests/target.yml b/RiotTests/target.yml index c32e1c8d9..ebf31a1d6 100644 --- a/RiotTests/target.yml +++ b/RiotTests/target.yml @@ -65,3 +65,6 @@ targets: - path: ../Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift - path: ../Riot/Managers/KeyValueStorage/ - path: ../Riot/PropertyWrappers/UserDefaultsBackedPropertyWrapper.swift + - path: ../RiotSwiftUI/Modules + includes: + - "**/Test/**" From f965620bfe102ddb6feec47644c3a917fee0d363 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 15:47:24 +0100 Subject: [PATCH 10/31] CoordinatorParamters, Type -> Protocol, remove MX Prefix. --- .../Avatar/AvatarViewDataProtocol.swift | 2 +- ...utType.swift => AvatarInputProtocol.swift} | 4 ++-- ...{AvatarType.swift => AvatarProtocol.swift} | 2 +- .../Common/Avatar/Model/Avatarable.swift | 2 +- ...vatarService.swift => AvatarService.swift} | 14 ++++++------- .../Service/Mock/MockAvatarService.swift | 4 ++-- .../Common/Avatar/View/AvatarImage.swift | 2 +- ...Type.swift => AvatarServiceProtocol.swift} | 2 +- .../Avatar/ViewModel/AvatarViewModel.swift | 2 +- .../RoomNotificationSettingsCoordinator.swift | 4 ++-- .../RoomNotificationSettingsViewState.swift | 2 +- ...oomNotificationSettingsViewStateType.swift | 2 +- .../View/RoomNotificationSettings.swift | 2 +- .../View/RoomNotificationSettingsHeader.swift | 2 +- .../RoomNotificationSettingsViewModel.swift | 2 +- .../TemplateUserProfileCoordinator.swift | 10 ++++----- ...lateUserProfileCoordinatorParamaters.swift | 21 +++++++++++++++++++ .../Model/TemplateUserProfileViewState.swift | 2 +- ...swift => TemplateUserProfileService.swift} | 2 +- .../View/TemplateUserProfileHeader.swift | 2 +- ...omNotificationSettingsViewModelTests.swift | 2 +- 21 files changed, 54 insertions(+), 33 deletions(-) rename RiotSwiftUI/Modules/Common/Avatar/Model/{AvatarInputType.swift => AvatarInputProtocol.swift} (90%) rename RiotSwiftUI/Modules/Common/Avatar/Model/{AvatarType.swift => AvatarProtocol.swift} (95%) rename RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/{MXAvatarService.swift => AvatarService.swift} (88%) rename RiotSwiftUI/Modules/Common/Avatar/ViewModel/{AvatarServiceType.swift => AvatarServiceProtocol.swift} (96%) create mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinatorParamaters.swift rename RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/{MXTemplateUserProfileService.swift => TemplateUserProfileService.swift} (96%) diff --git a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift index 9291723ad..6adc43baa 100644 --- a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift +++ b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift @@ -17,7 +17,7 @@ import Foundation /// AvatarViewDataProtocol describe a view data that should be given to an AvatarView sublcass -protocol AvatarViewDataProtocol: AvatarType { +protocol AvatarViewDataProtocol: AvatarProtocol { /// Matrix item identifier (user id or room id) var matrixItemId: String { get } diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputType.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputProtocol.swift similarity index 90% rename from RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputType.swift rename to RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputProtocol.swift index 160cd2e53..edfd86099 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputType.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarInputProtocol.swift @@ -16,13 +16,13 @@ import Foundation -protocol AvatarInputType: AvatarType { +protocol AvatarInputProtocol: AvatarProtocol { var mxContentUri: String? { get } var matrixItemId: String { get } var displayName: String? { get } } -struct AvatarInput: AvatarInputType { +struct AvatarInput: AvatarInputProtocol { let mxContentUri: String? var matrixItemId: String let displayName: String? diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarType.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarProtocol.swift similarity index 95% rename from RiotSwiftUI/Modules/Common/Avatar/Model/AvatarType.swift rename to RiotSwiftUI/Modules/Common/Avatar/Model/AvatarProtocol.swift index 75c673907..7963c333a 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarType.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/AvatarProtocol.swift @@ -16,4 +16,4 @@ import Foundation -protocol AvatarType { } +protocol AvatarProtocol { } diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift index 417e1e5ab..4f498f55a 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift @@ -22,7 +22,7 @@ import Foundation E.g. MXRoom, MxUser cna conform to this making it easy to grab the avatar data for display. */ -protocol Avatarable: AvatarInputType { } +protocol Avatarable: AvatarInputProtocol { } extension Avatarable { var avatarData: AvatarInput { AvatarInput( diff --git a/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift similarity index 88% rename from RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift rename to RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift index bfac9549c..4aa3fdb32 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/MXAvatarService.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift @@ -19,12 +19,12 @@ import MatrixSDK import Combine import DesignKit -enum MXAvatarServiceError: Error { +enum AvatarServiceError: Error { case pathNotfound case loadingImageFailed(Error?) } -class MXAvatarService: AvatarServiceType { +class AvatarService: AvatarServiceProtocol { private enum Constants { static let mimeType = "image/jpeg" @@ -33,8 +33,8 @@ class MXAvatarService: AvatarServiceType { private let mediaManager: MXMediaManager - static func instantiate(mediaManager: MXMediaManager) -> AvatarServiceType { - return MXAvatarService(mediaManager: mediaManager) + static func instantiate(mediaManager: MXMediaManager) -> AvatarServiceProtocol { + return AvatarService(mediaManager: mediaManager) } init(mediaManager: MXMediaManager) { @@ -73,18 +73,18 @@ class MXAvatarService: AvatarServiceType { toFitViewSize: avatarSize.size, with: Constants.thumbnailMethod) { path in guard let path = path else { - promise(.failure(MXAvatarServiceError.pathNotfound)) + promise(.failure(AvatarServiceError.pathNotfound)) return } guard let image = MXMediaManager.loadThroughCache(withFilePath: path), let imageUp = Self.orientImageUp(image: image) else { - promise(.failure(MXAvatarServiceError.loadingImageFailed(nil))) + promise(.failure(AvatarServiceError.loadingImageFailed(nil))) return } promise(.success(imageUp)) } failure: { error in - promise(.failure(MXAvatarServiceError.loadingImageFailed(error))) + promise(.failure(AvatarServiceError.loadingImageFailed(error))) } } } diff --git a/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift index 0ee87d429..e8032ee07 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift @@ -20,8 +20,8 @@ import DesignKit import UIKit @available(iOS 14.0, *) -class MockAvatarService: AvatarServiceType { - static let example: AvatarServiceType = MockAvatarService() +class MockAvatarService: AvatarServiceProtocol { + static let example: AvatarServiceProtocol = MockAvatarService() func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future { Future { promise in promise(.success(Asset.Images.appSymbol.image)) diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift index 4594b9ff6..ccc315a73 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift @@ -66,7 +66,7 @@ struct AvatarImage: View { @available(iOS 14.0, *) extension AvatarImage { - init(avatarData: AvatarInputType, size: AvatarSize) { + init(avatarData: AvatarInputProtocol, size: AvatarSize) { self.init( mxContentUri: avatarData.mxContentUri, matrixItemId: avatarData.matrixItemId, diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceType.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift similarity index 96% rename from RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceType.swift rename to RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift index 6025a002d..2d51df330 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceType.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift @@ -22,7 +22,7 @@ import UIKit /** Provides a simple api to retrieve and cache avatar images */ -protocol AvatarServiceType { +protocol AvatarServiceProtocol { @available(iOS 14.0, *) func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future } diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift index 6ac6613ff..f6a81f6d1 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -25,7 +25,7 @@ import DesignKit @available(iOS 14.0, *) class AvatarViewModel: InjectableObject, ObservableObject { - @Inject var avatarService: AvatarServiceType + @Inject var avatarService: AvatarServiceProtocol @Published private(set) var viewState = AvatarViewState.empty diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift index be17cf926..6d79f5e67 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift @@ -39,7 +39,7 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin init(room: MXRoom, presentedModally: Bool = true) { let roomNotificationService = MXRoomNotificationSettingsService(room: room) - let avatarData: AvatarType? + let avatarData: AvatarProtocol? let showAvatar = presentedModally if #available(iOS 14.0.0, *) { avatarData = showAvatar ? AvatarInput( @@ -64,7 +64,7 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin avatarData: avatarData, displayName: room.summary.displayname, roomEncrypted: room.summary.isEncrypted) - let avatarService: AvatarServiceType = MXAvatarService(mediaManager: room.mxSession.mediaManager) + let avatarService: AvatarServiceProtocol = AvatarService(mediaManager: room.mxSession.mediaManager) let view = RoomNotificationSettings(viewModel: swiftUIViewModel, presentedModally: presentedModally) .addDependency(avatarService) let host = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewState.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewState.swift index 28ec9585b..ba8e92916 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewState.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewState.swift @@ -21,7 +21,7 @@ struct RoomNotificationSettingsViewState: RoomNotificationSettingsViewStateType let roomEncrypted: Bool var saving: Bool var notificationState: RoomNotificationState - var avatarData: AvatarType? + var avatarData: AvatarProtocol? var displayName: String? } diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewStateType.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewStateType.swift index ac8c767da..0b9c7e0b6 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewStateType.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/Model/RoomNotificationSettingsViewStateType.swift @@ -23,7 +23,7 @@ protocol RoomNotificationSettingsViewStateType { var roomEncrypted: Bool { get } var notificationOptions: [RoomNotificationState] { get } var notificationState: RoomNotificationState { get } - var avatarData: AvatarType? { get } + var avatarData: AvatarProtocol? { get } var displayName: String? { get } } diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettings.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettings.swift index 9490635b7..5265fff86 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettings.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettings.swift @@ -41,7 +41,7 @@ struct RoomNotificationSettings: View { var body: some View { VectorForm { - if let avatarData = viewModel.viewState.avatarData as? AvatarInputType { + if let avatarData = viewModel.viewState.avatarData as? AvatarInputProtocol { RoomNotificationSettingsHeader( avatarData: avatarData, displayName: viewModel.viewState.displayName diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettingsHeader.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettingsHeader.swift index 80af68030..7379eab71 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettingsHeader.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/View/RoomNotificationSettingsHeader.swift @@ -20,7 +20,7 @@ import SwiftUI struct RoomNotificationSettingsHeader: View { @Environment(\.theme) var theme: ThemeSwiftUI - var avatarData: AvatarInputType + var avatarData: AvatarInputProtocol var displayName: String? var body: some View { diff --git a/RiotSwiftUI/Modules/Room/NotificationSettings/ViewModel/RoomNotificationSettingsViewModel.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/ViewModel/RoomNotificationSettingsViewModel.swift index 8ac02df4e..6b09b81d6 100644 --- a/RiotSwiftUI/Modules/Room/NotificationSettings/ViewModel/RoomNotificationSettingsViewModel.swift +++ b/RiotSwiftUI/Modules/Room/NotificationSettings/ViewModel/RoomNotificationSettingsViewModel.swift @@ -55,7 +55,7 @@ class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModelType { convenience init( roomNotificationService: RoomNotificationSettingsServiceType, - avatarData: AvatarType?, + avatarData: AvatarProtocol?, displayName: String?, roomEncrypted: Bool ) { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 488de41ec..d710b713d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -24,7 +24,7 @@ final class TemplateUserProfileCoordinator: Coordinator { // MARK: Private - private let session: MXSession + private let parameters: TemplateUserProfileCoordinatorParameters private let templateUserProfileViewController: UIViewController private var templateUserProfileViewModel: TemplateUserProfileViewModelProtocol @@ -37,11 +37,11 @@ final class TemplateUserProfileCoordinator: Coordinator { // MARK: - Setup @available(iOS 14.0, *) - init(session: MXSession) { - self.session = session - let viewModel = TemplateUserProfileViewModel(userService: MXTemplateUserProfileService(session: session)) + init(parameters: TemplateUserProfileCoordinatorParameters) { + self.parameters = parameters + let viewModel = TemplateUserProfileViewModel(userService: TemplateUserProfileService(session: parameters.session)) let view = TemplateUserProfile(viewModel: viewModel) - .addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager)) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) templateUserProfileViewModel = viewModel templateUserProfileViewController = VectorHostingController(rootView: view) } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinatorParamaters.swift new file mode 100644 index 000000000..17be5c41e --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinatorParamaters.swift @@ -0,0 +1,21 @@ +// +// 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 + +struct TemplateUserProfileCoordinatorParameters { + let session: MXSession +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift index 8d8855262..1634b8c1d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift @@ -17,7 +17,7 @@ import Foundation struct TemplateUserProfileViewState { - let avatar: AvatarInputType? + let avatar: AvatarInputProtocol? let displayName: String? var presence: TemplateUserProfilePresence } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift similarity index 96% rename from RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift index 889c5824d..c898ca52e 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/MXTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -18,7 +18,7 @@ import Foundation import Combine @available(iOS 14.0, *) -class MXTemplateUserProfileService: TemplateUserProfileServiceProtocol { +class TemplateUserProfileService: TemplateUserProfileServiceProtocol { // MARK: - Properties diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift index 72edf71fa..5f0b78177 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift @@ -24,7 +24,7 @@ struct TemplateUserProfileHeader: View { // MARK: Public @Environment(\.theme) var theme: ThemeSwiftUI - let avatar: AvatarInputType? + let avatar: AvatarInputProtocol? let displayName: String? let presence: TemplateUserProfilePresence diff --git a/RiotTests/RoomNotificationSettingsViewModelTests.swift b/RiotTests/RoomNotificationSettingsViewModelTests.swift index 7c619f3b5..2d39b81ca 100644 --- a/RiotTests/RoomNotificationSettingsViewModelTests.swift +++ b/RiotTests/RoomNotificationSettingsViewModelTests.swift @@ -60,7 +60,7 @@ class RoomNotificationSettingsViewModelTests: XCTestCase { } func setupViewModel(roomEncrypted: Bool, showAvatar: Bool) { - let avatarData: AvatarType? = showAvatar ? Constants.avatarData : nil + let avatarData: AvatarProtocol? = showAvatar ? Constants.avatarData : nil let viewModel = RoomNotificationSettingsViewModel(roomNotificationService: service, avatarData: avatarData, displayName: Constants.roomDisplayName, roomEncrypted: roomEncrypted) viewModel.viewDelegate = view viewModel.coordinatorDelegate = coordinator From 15a7b43423727bbcee32fc665d87d2b887e8938a Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 16:28:08 +0100 Subject: [PATCH 11/31] Extract presence listener setup out to own function. --- .../TemplateUserProfileService.swift | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift index c898ca52e..b08492f5c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -25,7 +25,8 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { // MARK: Private private let session: MXSession - private var listenerReference: Any! + private var listenerReference: Any? + @Published private var presence: TemplateUserProfilePresence = .offline // MARK: Public @@ -50,19 +51,28 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { init(session: MXSession) { self.session = session - - let listenerReference = session.myUser.listen { [weak self] event in + self.listenerReference = setupPresenceListener() + } + + deinit { + guard let reference = listenerReference else { return } + session.myUser.removeListener(reference) + } + + func setupPresenceListener() -> Any? { + let reference = session.myUser.listen { [weak self] event in guard let self = self, let event = event, case .presence = MXEventType(identifier: event.eventId) else { return } self.presence = TemplateUserProfilePresence(mxPresence: self.session.myUser.presence) } - self.listenerReference = listenerReference - } - - deinit { - session.myUser.removeListener(listenerReference) + guard let reference = reference else { + // TODO: Add log back when abstract logger added to RiotSwiftUI +// MXLog.error("[TemplateUserProfileService] Did not recieve a lisenter reference.") + return nil + } + return reference } } From ad287d8deabee089494b54379c12915b091a88e1 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 16:37:56 +0100 Subject: [PATCH 12/31] Fix retain cycle --- .../ViewModel/TemplateUserProfileViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index ce00fe627..38e0dde21 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -39,7 +39,9 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod userService.presencePublisher .map(TemplateUserProfileStateAction.updatePresence) .receive(on: DispatchQueue.main) - .sink(receiveValue: self.dispatch(action:)) + .sink(receiveValue: { [weak self] action in + self?.dispatch(action:action) + }) .store(in: &cancellables) } From 0826ccdb317f58d323f1923e889949b6286e05d6 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 8 Sep 2021 17:56:51 +0100 Subject: [PATCH 13/31] fix build --- .../Service/MatrixSDK/TemplateUserProfileService.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift index b08492f5c..a4a449637 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -67,11 +67,11 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { else { return } self.presence = TemplateUserProfilePresence(mxPresence: self.session.myUser.presence) } - guard let reference = reference else { - // TODO: Add log back when abstract logger added to RiotSwiftUI +// TODO: Add log back when abstract logger added to RiotSwiftUI +// if reference == nil { +// // MXLog.error("[TemplateUserProfileService] Did not recieve a lisenter reference.") - return nil - } +// } return reference } } From 44abb7ecd46c0d0b9358ad8228b8d0c6d4d207ad Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 10:31:41 +0100 Subject: [PATCH 14/31] Apply documentation suggestions from code review Co-authored-by: manuroe --- RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift | 4 ++-- .../Modules/Common/Test/XCTestPublisherExtensions.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift index 4f498f55a..173299a3b 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift @@ -17,9 +17,9 @@ import Foundation /** - A protcol that any class or struct can conform to + A protocol that any class or struct can conform to so that it can easiy produce avatar data. - E.g. MXRoom, MxUser cna conform to this making it + E.g. MXRoom, MxUser can conform to this making it easy to grab the avatar data for display. */ protocol Avatarable: AvatarInputProtocol { } diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift index 68fb9ddc3..2eef28e36 100644 --- a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -18,7 +18,7 @@ import XCTest import Combine /** - XCTest utility to wat for results from publishers, so that the output can be used for assertions. + XCTest utility to wait for results from publishers, so that the output can be used for assertions. */ @available(iOS 14.0, *) extension XCTestCase { From 4af9b66f565e02314cc2f251c8803ded7f3d1829 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 10:34:25 +0100 Subject: [PATCH 15/31] Update RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift Co-authored-by: manuroe --- .../ViewModel/TemplateUserProfileViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 38e0dde21..9eb36fb5f 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -70,7 +70,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod } /** - A redux style reducer, all modifications to state happen here. Recieves a state and a state action and produces a new state. + A redux style reducer, all modifications to state happen here. Receives a state and a state action and produces a new state. */ private func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { switch action { From f3d9ea49f0dd0e6eca6ce71d47fbbd1406da4a5d Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 10:50:39 +0100 Subject: [PATCH 16/31] Make reducer static, pass viewState directly to reducer, log reducer. --- .../ViewModel/TemplateUserProfileViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 38e0dde21..6976d1004 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -64,19 +64,19 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod Send state actions to mutate the state. */ private func dispatch(action: TemplateUserProfileStateAction) { - var newState = self.viewState - reducer(state: &newState, action: action) - self.viewState = newState + Self.reducer(state: &self.viewState, action: action) } /** A redux style reducer, all modifications to state happen here. Recieves a state and a state action and produces a new state. */ - private func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { + private static func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { switch action { case .updatePresence(let presence): state.presence = presence } +// TODO: Uncomment when we have an abstract logger for RiotSwiftUI +// MXLog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") } private func done() { From 2d212ddd8e3f85234669d6a89eeb208df08134ed Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 16:23:00 +0100 Subject: [PATCH 17/31] UITests and Mock Screens Adds the test targets for Unit and UI tests Adds mock screen data and utilities to render each mock screen for previews/ui tests. Changes Published property in the service to CurrentValueSubject. we don't need the synthesized aspect of Published and property wrappers cannot be included in protocols. --- .../Modules/Common/Mock/MockScreen.swift | 58 +++++++++++++++++ .../TemplateUserProfileService.swift | 9 +-- .../Mock/MockTemplateProfileUserScreen.swift | 45 ++++++++++++++ .../Mock/MockTemplateUserProfileService.swift | 28 +++++---- .../TemplateUserProfileServiceProtocol.swift | 2 +- .../Test/UI/TestUserProfileUITests.swift | 47 ++++++++++++++ .../TemplateUserProfileViewModelTests.swift | 19 +++--- .../View/TemplateUserProfile.swift | 4 +- .../TemplateUserProfileViewModel.swift | 4 +- RiotSwiftUI/RiotSwiftUIApp.swift | 3 +- RiotSwiftUI/targetUITests.yml | 62 +++++++++++++++++++ RiotSwiftUI/targetUnitTests.yml | 57 +++++++++++++++++ RiotTests/target.yml | 3 - project.yml | 2 + 14 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 RiotSwiftUI/Modules/Common/Mock/MockScreen.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreen.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift rename RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/{ => Unit}/TemplateUserProfileViewModelTests.swift (69%) create mode 100644 RiotSwiftUI/targetUITests.yml create mode 100644 RiotSwiftUI/targetUnitTests.yml diff --git a/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift b/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift new file mode 100644 index 000000000..55adc3cb7 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift @@ -0,0 +1,58 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/* + Used for mocking top level screens and their various state. + */ +@available(iOS 14.0, *) +protocol MockScreen { + associatedtype ScreenType: View + static func screen(for state: Self) -> ScreenType + static var screenStates: [Self] { get } +} + + +@available(iOS 14.0, *) +extension MockScreen { + + /* + Get a list of the screens for every screen state. + */ + static var screens: [ScreenType] { + Self.screenStates.map(screen(for:)) + } + + /* + Render each of the screen states in a group applying + any optional environment variables. + */ + static func screenGroup( + themeId: ThemeIdentifier = .light, + locale: Locale = Locale.current, + sizeCategory: ContentSizeCategory = ContentSizeCategory.medium + ) -> some View { + Group { + ForEach(0.. { - $presence.eraseToAnyPublisher() - } + private(set) var presenceSubject: CurrentValueSubject // MARK: - Setup init(session: MXSession) { self.session = session + self.presenceSubject = CurrentValueSubject(TemplateUserProfilePresence(mxPresence: session.myUser.presence)) self.listenerReference = setupPresenceListener() } @@ -65,7 +62,7 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { let event = event, case .presence = MXEventType(identifier: event.eventId) else { return } - self.presence = TemplateUserProfilePresence(mxPresence: self.session.myUser.presence) + self.presenceSubject.send(TemplateUserProfilePresence(mxPresence: self.session.myUser.presence)) } // TODO: Add log back when abstract logger added to RiotSwiftUI // if reference == nil { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreen.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreen.swift new file mode 100644 index 000000000..a23c630a5 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreen.swift @@ -0,0 +1,45 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/** + Using an enum for the screen allows you define the different state cases with + the relevant associated data for each case. + */ +@available(iOS 14.0, *) +enum MockTemplateProfileUserScreenStates: MockScreen { + + case mockPresenceStates(TemplateUserProfilePresence) + case mockLongDisplayName(String) + + static var screenStates: [MockTemplateProfileUserScreenStates] = TemplateUserProfilePresence.allCases.map(MockTemplateProfileUserScreenStates.mockPresenceStates) + + [.mockLongDisplayName("Somebody with a super long name we would like to test")] + + static func screen(for state: MockTemplateProfileUserScreenStates) -> some View { + let service: MockTemplateUserProfileService + switch state { + case .mockPresenceStates(let presence): + service = MockTemplateUserProfileService(presence: presence) + case .mockLongDisplayName(let displayName): + service = MockTemplateUserProfileService(displayName: displayName) + } + let viewModel = TemplateUserProfileViewModel(userService: service) + return TemplateUserProfile(viewModel: viewModel) + .addDependency(MockAvatarService.example) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 1f6a8f570..9a713fedc 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -19,20 +19,24 @@ import Combine @available(iOS 14.0, *) class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { - - static let example = MockTemplateUserProfileService() - static let initialPresenceState: TemplateUserProfilePresence = .offline - @Published var presence: TemplateUserProfilePresence = initialPresenceState - var presencePublisher: AnyPublisher { - $presence.eraseToAnyPublisher() + var presenceSubject: CurrentValueSubject + + let userId: String + var displayName: String? + let avatarUrl: String? + init( + userId: String = "123", + displayName: String? = "Alice", + avatarUrl: String? = "mx123@matrix.com", + presence: TemplateUserProfilePresence = .offline + ) { + self.userId = userId + self.displayName = displayName + self.avatarUrl = avatarUrl + self.presenceSubject = CurrentValueSubject(presence) } - let userId: String = "123" - let displayName: String? = "Alice" - let avatarUrl: String? = "mx123@matrix.com" - let currentlyActive: Bool = true - let lastActive: UInt = 1630596918513 func simulateUpdate(presence: TemplateUserProfilePresence) { - self.presence = presence + self.presenceSubject.send(presence) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift index 581d7cc37..452a6b037 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift @@ -22,7 +22,7 @@ protocol TemplateUserProfileServiceProtocol: Avatarable { var userId: String { get } var displayName: String? { get } var avatarUrl: String? { get } - var presencePublisher: AnyPublisher { get } + var presenceSubject: CurrentValueSubject { get } } @available(iOS 14.0, *) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift new file mode 100644 index 000000000..5ec87e61d --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift @@ -0,0 +1,47 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class TestUserProfileUITests: XCTestCase { + + let app = XCUIApplication() + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. + app.launch() + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testUserContentTextDisplayed() throws { + let userContentText = app.staticTexts["More great user content!"] + XCTAssert(userContentText.exists) + } + +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift similarity index 69% rename from RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift index cff2dba86..e7724c6d8 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/TemplateUserProfileViewModelTests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -17,27 +17,30 @@ import XCTest import Combine -@testable import Riot +@testable import RiotSwiftUI @available(iOS 14.0, *) class TemplateUserProfileViewModelTests: XCTestCase { + private enum Constants { + static let presenceInitialValue: TemplateUserProfilePresence = .offline + static let displayName = "Alice" + } var service: MockTemplateUserProfileService! var viewModel: TemplateUserProfileViewModel! var cancellables = Set() override func setUpWithError() throws { - service = MockTemplateUserProfileService() + service = MockTemplateUserProfileService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) viewModel = TemplateUserProfileViewModel(userService: service) } func testInitialState() { - XCTAssertEqual(viewModel.viewState.displayName, MockTemplateUserProfileService.example.displayName) - XCTAssertEqual(viewModel.viewState.avatar?.mxContentUri, MockTemplateUserProfileService.example.avatarUrl) - XCTAssertEqual(viewModel.viewState.presence, MockTemplateUserProfileService.initialPresenceState) + XCTAssertEqual(viewModel.viewState.displayName, Constants.displayName) + XCTAssertEqual(viewModel.viewState.presence, Constants.presenceInitialValue) } func testFirstPresenceRecieved() throws { let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(1).first() - XCTAssertEqual(try xcAwait(presencePublisher), [MockTemplateUserProfileService.initialPresenceState]) + XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) } func testPresenceUpdatesRecieved() throws { @@ -46,8 +49,6 @@ class TemplateUserProfileViewModelTests: XCTestCase { let newPresenceValue2: TemplateUserProfilePresence = .idle service.simulateUpdate(presence: newPresenceValue1) service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try xcAwait(presencePublisher), [MockTemplateUserProfileService.initialPresenceState, newPresenceValue1, newPresenceValue2]) + XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) } - - } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index b3f059f2b..47582f2cd 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -45,6 +45,7 @@ struct TemplateUserProfile: View { } .frame(maxHeight: .infinity) } + .background(theme.colors.background) .frame(maxHeight: .infinity) .navigationTitle(viewModel.viewState.displayName ?? "") .navigationBarItems(leading: leftButton, trailing: rightButton) @@ -70,7 +71,6 @@ struct TemplateUserProfile: View { @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserProfileService.example)) - .addDependency(MockAvatarService.example) + MockTemplateProfileUserScreenStates.screenGroup() } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 56de3caf7..e64536381 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -36,7 +36,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod self.userService = userService self.viewState = initialState ?? Self.defaultState(userService: userService) - userService.presencePublisher + userService.presenceSubject .map(TemplateUserProfileStateAction.updatePresence) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] action in @@ -46,7 +46,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod } private static func defaultState(userService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { - return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName, presence: .offline) + return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName, presence: userService.presenceSubject.value) } // MARK: - Public diff --git a/RiotSwiftUI/RiotSwiftUIApp.swift b/RiotSwiftUI/RiotSwiftUIApp.swift index 417ae1872..478d6f5d4 100644 --- a/RiotSwiftUI/RiotSwiftUIApp.swift +++ b/RiotSwiftUI/RiotSwiftUIApp.swift @@ -16,8 +16,7 @@ import SwiftUI /** - Just needed so the application target has an entry point for the moment. - Could use to render the different screens. + RiotSwiftUI screens rendered for UI Tests. */ @available(iOS 14.0, *) @main diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml new file mode 100644 index 000000000..187c7c635 --- /dev/null +++ b/RiotSwiftUI/targetUITests.yml @@ -0,0 +1,62 @@ +name: RiotSwiftUITests + +schemes: + RiotSwiftUITests: + analyze: + config: Debug + archive: + config: Release + build: + targets: + RiotSwiftUITests: + - running + - testing + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + test: + config: Debug + disableMainThreadChecker: true + targets: + - RiotSwiftUITests + +targets: + RiotSwiftUITests: + type: bundle.ui-testing + platform: iOS + + dependencies: + - target: RiotSwiftUI + +# configFiles: +# Debug: Debug.xcconfig +# Release: Release.xcconfig + + settings: + base: + TEST_TARGET_NAME: RiotSwiftUI +# PRODUCT_NAME: RiotSwiftUITests + PRODUCT_BUNDLE_IDENTIFIER: org.matrix.RiotSwiftUITests$(rfc1034identifier) +# BUNDLE_LOADER: $(TEST_HOST) +# FRAMEWORK_SEARCH_PATHS: $(SDKROOT)/Developer/Library/Frameworks $(inherited) +# INFOPLIST_FILE: RiotSwiftUI/Info.plist +# LD_RUNPATH_SEARCH_PATHS: $(inherited) @executable_path/Frameworks @loader_path/Frameworks +# PRODUCT_BUNDLE_IDENTIFIER: org.matrix.$(PRODUCT_NAME:rfc1034identifier) +# PRODUCT_NAME: RiotSwiftUITests +# TEST_TARGET_NAME: $(BUILT_PRODUCTS_DIR)/RiotSwiftUI.app/RiotSwiftUI +# configs: +# Debug: +# Release: +# PROVISIONING_PROFILE: $(RIOT_PROVISIONING_PROFILE) +# PROVISIONING_PROFILE_SPECIFIER: $(RIOT_PROVISIONING_PROFILE_SPECIFIER) + sources: + - path: ../RiotSwiftUI/Modules + includes: + - "**/Test" + excludes: + - "**/Test/Unit/**" diff --git a/RiotSwiftUI/targetUnitTests.yml b/RiotSwiftUI/targetUnitTests.yml new file mode 100644 index 000000000..dbf54400c --- /dev/null +++ b/RiotSwiftUI/targetUnitTests.yml @@ -0,0 +1,57 @@ +name: RiotSwiftUnitTests + +schemes: + RiotSwiftUnitTests: + analyze: + config: Debug + archive: + config: Release + build: + targets: + RiotSwiftUnitTests: + - running + - testing + - profiling + - analyzing + - archiving + profile: + config: Release + run: + config: Debug + disableMainThreadChecker: true + test: + config: Debug + disableMainThreadChecker: true + targets: + - RiotSwiftUnitTests + +targets: + RiotSwiftUnitTests: + type: bundle.unit-test + platform: iOS + + dependencies: + - target: RiotSwiftUI + + configFiles: + Debug: Debug.xcconfig + Release: Release.xcconfig + + settings: + base: + FRAMEWORK_SEARCH_PATHS: $(SDKROOT)/Developer/Library/Frameworks $(inherited) + INFOPLIST_FILE: RiotSwiftUI/Info.plist + LD_RUNPATH_SEARCH_PATHS: $(inherited) @executable_path/Frameworks @loader_path/Frameworks + PRODUCT_BUNDLE_IDENTIFIER: org.matrix.$(PRODUCT_NAME:rfc1034identifier) + PRODUCT_NAME: RiotSwiftUnitTests + configs: + Debug: + Release: + PROVISIONING_PROFILE: $(RIOT_PROVISIONING_PROFILE) + PROVISIONING_PROFILE_SPECIFIER: $(RIOT_PROVISIONING_PROFILE_SPECIFIER) + sources: + - path: ../RiotSwiftUI/Modules + includes: + - "**/Test" + excludes: + - "**/Test/UI/**" diff --git a/RiotTests/target.yml b/RiotTests/target.yml index ebf31a1d6..c32e1c8d9 100644 --- a/RiotTests/target.yml +++ b/RiotTests/target.yml @@ -65,6 +65,3 @@ targets: - path: ../Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift - path: ../Riot/Managers/KeyValueStorage/ - path: ../Riot/PropertyWrappers/UserDefaultsBackedPropertyWrapper.swift - - path: ../RiotSwiftUI/Modules - includes: - - "**/Test/**" diff --git a/project.yml b/project.yml index 3f825be46..44c6eeb12 100644 --- a/project.yml +++ b/project.yml @@ -33,3 +33,5 @@ include: - path: RiotNSE/target.yml - path: DesignKit/target.yml - path: RiotSwiftUI/target.yml + - path: RiotSwiftUI/targetUnitTests.yml + - path: RiotSwiftUI/targetUITests.yml From c80dfcd1d046bfc0fb73edcee7d013293623b376 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 17:00:45 +0100 Subject: [PATCH 18/31] Update based on comments from Doug --- .../Common/Avatar/Model/Avatarable.swift | 2 +- .../Test/XCTestPublisherExtensions.swift | 5 +++ .../TemplateUserProfileCoordinator.swift | 6 +-- .../TemplateUserProfileService.swift | 4 +- .../View/TemplateUserProfile.swift | 40 +++++++++---------- .../View/TemplateUserProfileHeader.swift | 13 +++--- .../TemplateUserProfilePresenceView.swift | 11 +++-- .../TemplateUserProfileViewModel.swift | 6 +-- 8 files changed, 42 insertions(+), 45 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift index 173299a3b..17a0ea1ee 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift @@ -18,7 +18,7 @@ import Foundation /** A protocol that any class or struct can conform to - so that it can easiy produce avatar data. + so that it can easily produce avatar data. E.g. MXRoom, MxUser can conform to this making it easy to grab the avatar data for display. */ diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift index 2eef28e36..06fcd96f7 100644 --- a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -19,6 +19,11 @@ import Combine /** XCTest utility to wait for results from publishers, so that the output can be used for assertions. + + ``` + let collectedEvents = somePublisher.collect(3).first() + XCTAssertEqual(try xcAwait(collectedEvents), [expected, values, here]) + ``` */ @available(iOS 14.0, *) extension XCTestCase { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index d710b713d..c42a297a7 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -25,7 +25,7 @@ final class TemplateUserProfileCoordinator: Coordinator { // MARK: Private private let parameters: TemplateUserProfileCoordinatorParameters - private let templateUserProfileViewController: UIViewController + private let templateUserProfileHostingController: UIViewController private var templateUserProfileViewModel: TemplateUserProfileViewModelProtocol // MARK: Public @@ -43,7 +43,7 @@ final class TemplateUserProfileCoordinator: Coordinator { let view = TemplateUserProfile(viewModel: viewModel) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) templateUserProfileViewModel = viewModel - templateUserProfileViewController = VectorHostingController(rootView: view) + templateUserProfileHostingController = VectorHostingController(rootView: view) } // MARK: - Public @@ -59,6 +59,6 @@ final class TemplateUserProfileCoordinator: Coordinator { } func toPresentable() -> UIViewController { - return self.templateUserProfileViewController + return self.templateUserProfileHostingController } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift index 9454ebfb2..bf08fba09 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -30,7 +30,7 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { // MARK: Public var userId: String { - return session.myUser.userId + session.myUser.userId } var displayName: String? { @@ -62,7 +62,7 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { let event = event, case .presence = MXEventType(identifier: event.eventId) else { return } - self.presenceSubject.send(TemplateUserProfilePresence(mxPresence: self.session.myUser.presence)) + self.presenceSubject.send(TemplateUserProfilePresence(mxPresence: self.session.myUser.presence)) } // TODO: Add log back when abstract logger added to RiotSwiftUI // if reference == nil { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index 47582f2cd..fc6fc7e76 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -21,9 +21,12 @@ struct TemplateUserProfile: View { // MARK: - Properties + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + // MARK: Public - @Environment(\.theme) var theme: ThemeSwiftUI @ObservedObject var viewModel: TemplateUserProfileViewModel var body: some View { @@ -35,33 +38,26 @@ struct TemplateUserProfile: View { ) Divider() VStack{ - HStack(alignment: .center){ - Spacer() - Text("More great user content!") - .font(theme.fonts.title2) - .foregroundColor(theme.colors.secondaryContent) - Spacer() - } + Text("More great user content!") + .font(theme.fonts.title2) + .foregroundColor(theme.colors.secondaryContent) } .frame(maxHeight: .infinity) } .background(theme.colors.background) .frame(maxHeight: .infinity) .navigationTitle(viewModel.viewState.displayName ?? "") - .navigationBarItems(leading: leftButton, trailing: rightButton) - } - - // MARK: Private - - private var leftButton: some View { - Button(VectorL10n.cancel) { - viewModel.proccess(viewAction: .cancel) - } - } - - private var rightButton: some View { - Button(VectorL10n.done) { - viewModel.proccess(viewAction: .cancel) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(VectorL10n.done) { + viewModel.process(viewAction: .cancel) + } + } + ToolbarItem(placement: .cancellationAction) { + Button(VectorL10n.cancel) { + viewModel.process(viewAction: .cancel) + } + } } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift index 5f0b78177..d32111d27 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift @@ -21,9 +21,10 @@ struct TemplateUserProfileHeader: View { // MARK: - Properties - // MARK: Public + // MARK: Private + @Environment(\.theme) private var theme: ThemeSwiftUI - @Environment(\.theme) var theme: ThemeSwiftUI + // MARK: Public let avatar: AvatarInputProtocol? let displayName: String? let presence: TemplateUserProfilePresence @@ -31,17 +32,13 @@ struct TemplateUserProfileHeader: View { var body: some View { VStack { if let avatar = avatar { - HStack{ - Spacer() - AvatarImage(avatarData: avatar, size: .xxLarge) - Spacer() - } + AvatarImage(avatarData: avatar, size: .xxLarge) .padding(.vertical) } VStack(spacing: 8){ Text(displayName ?? "") .font(theme.fonts.title3) - TemplateUserProfilePresenceView(presense: presence) + TemplateUserProfilePresenceView(presence: presence) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift index c5e825515..85b78ea26 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift @@ -22,25 +22,24 @@ struct TemplateUserProfilePresenceView: View { // MARK: - Properties // MARK: Public - let presense: TemplateUserProfilePresence + let presence: TemplateUserProfilePresence var body: some View { HStack { Image(systemName: "circle.fill") .resizable() .frame(width: 8, height: 8) - .foregroundColor(foregroundColor) - Text(presense.title) + Text(presence.title) .font(.subheadline) } .foregroundColor(foregroundColor) .padding(0) } - // MARK: Private + // MARK: View Components private var foregroundColor: Color { - switch presense { + switch presence { case .online: return .green case .idle: @@ -59,7 +58,7 @@ struct TemplateUserProfilePresenceView_Previews: PreviewProvider { VStack(alignment:.leading){ Text("Presence") ForEach(TemplateUserProfilePresence.allCases) { presence in - TemplateUserProfilePresenceView(presense: presence) + TemplateUserProfilePresenceView(presence: presence) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index e64536381..45a70d253 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -50,12 +50,12 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod } // MARK: - Public - func proccess(viewAction: TemplateUserProfileViewAction) { + func process(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: - self.cancel() + cancel() case .done: - self.done() + done() } } From 59a54654b1431c8ca9d6b8414991fc8c5a25cec3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 9 Sep 2021 22:42:00 +0100 Subject: [PATCH 19/31] Add configurable logger for RiotSwiftUI --- Riot/Managers/Logging/MatrixSDKLogger.swift | 39 ++++++++++ Riot/Modules/Application/AppCoordinator.swift | 4 + .../Avatar/ViewModel/AvatarViewModel.swift | 3 +- .../Common/Logging/LoggerProtocol.swift | 29 ++++++++ .../Modules/Common/Logging/PrintLogger.swift | 39 ++++++++++ .../Modules/Common/Logging/UILog.swift | 73 +++++++++++++++++++ .../TemplateUserProfileService.swift | 8 +- .../TemplateUserProfileViewModel.swift | 3 +- RiotSwiftUI/RiotSwiftUIApp.swift | 5 +- 9 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 Riot/Managers/Logging/MatrixSDKLogger.swift create mode 100644 RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift create mode 100644 RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift create mode 100644 RiotSwiftUI/Modules/Common/Logging/UILog.swift diff --git a/Riot/Managers/Logging/MatrixSDKLogger.swift b/Riot/Managers/Logging/MatrixSDKLogger.swift new file mode 100644 index 000000000..3012f7bb3 --- /dev/null +++ b/Riot/Managers/Logging/MatrixSDKLogger.swift @@ -0,0 +1,39 @@ +// +// 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 + +/** + A logger for logging to MXLog. + For use with UILog. + */ +class MatrixSDKLogger: LoggerProtocol { + static func verbose(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + MXLog.verbose(message(), file, function, line: line, context: context) + } + static func debug(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + MXLog.debug(message(), file, function, line: line, context: context) + } + static func info(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + MXLog.info(message(), file, function, line: line, context: context) + } + static func warning(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + MXLog.warning(message(), file, function, line: line, context: context) + } + static func error(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + MXLog.error(message(), file, function, line: line, context: context) + } +} diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index 12cc4e3f3..bb9b653c2 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -75,6 +75,7 @@ final class AppCoordinator: NSObject, AppCoordinatorType { // MARK: - Public methods func start() { + self.setupLogger() self.setupTheme() if BuildSettings.enableSideMenu { @@ -100,6 +101,9 @@ final class AppCoordinator: NSObject, AppCoordinatorType { } // MARK: - Private methods + private func setupLogger() { + UILog.configure(logger: MatrixSDKLogger.self) + } private func setupTheme() { ThemeService.shared().themeId = RiotSettings.shared.userInterfaceTheme diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift index f6a81f6d1..1612da1c2 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -47,8 +47,7 @@ class AvatarViewModel: InjectableObject, ObservableObject { avatarService.avatarImage(mxContentUri: mxContentUri, avatarSize: avatarSize) .sink { completion in guard case let .failure(error) = completion else { return } -// MXLog.error("[AvatarService] Failed to retrieve avatar: \(error)") - // TODO: Report non-fatal error when we have Sentry or similar. + UILog.error("[AvatarService] Failed to retrieve avatar: \(error)") } receiveValue: { image in self.viewState = .avatar(image) } diff --git a/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift new file mode 100644 index 000000000..8930f07c7 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift @@ -0,0 +1,29 @@ +// +// 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 + +/** + A logger protocol that enables confirming types + to be used with UILog. + */ +protocol LoggerProtocol { + static func verbose(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) + static func debug(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) + static func info(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) + static func warning(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) + static func error(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) +} diff --git a/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift b/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift new file mode 100644 index 000000000..faf90b540 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift @@ -0,0 +1,39 @@ +// +// 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 + +/** + A logger for logging to `print`. + For use with UILog. + */ +class PrintLogger: LoggerProtocol { + static func verbose(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + print(message()) + } + static func debug(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + print(message()) + } + static func info(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + print(message()) + } + static func warning(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + print(message()) + } + static func error(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { + print(message()) + } +} diff --git a/RiotSwiftUI/Modules/Common/Logging/UILog.swift b/RiotSwiftUI/Modules/Common/Logging/UILog.swift new file mode 100644 index 000000000..9874be30c --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Logging/UILog.swift @@ -0,0 +1,73 @@ +// +// 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 +/* + A logger for use in different application targets that can be configured + at runtime with a suitable logger. + */ +class UILog: LoggerProtocol { + + static var _logger: LoggerProtocol.Type? + static func configure(logger: LoggerProtocol.Type) { + _logger = logger + } + + static func verbose( + _ message: @autoclosure () -> Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + _logger?.verbose(message(), file, function, line: line, context: context) + } + + static func debug( + _ message: @autoclosure () -> Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + _logger?.debug(message(), file, function, line: line, context: context) + } + + static func info( + _ message: @autoclosure () -> Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + _logger?.info(message(), file, function, line: line, context: context) + } + + static func warning( + _ message: @autoclosure () -> Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + _logger?.warning(message(), file, function, line: line, context: context) + } + + static func error( + _ message: @autoclosure () -> Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + _logger?.error(message(), file, function, line: line, context: context) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift index bf08fba09..d42fd39a1 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -64,11 +64,9 @@ class TemplateUserProfileService: TemplateUserProfileServiceProtocol { else { return } self.presenceSubject.send(TemplateUserProfilePresence(mxPresence: self.session.myUser.presence)) } -// TODO: Add log back when abstract logger added to RiotSwiftUI -// if reference == nil { -// -// MXLog.error("[TemplateUserProfileService] Did not recieve a lisenter reference.") -// } + if reference == nil { + UILog.error("[TemplateUserProfileService] Did not recieve a lisenter reference.") + } return reference } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 45a70d253..cf625e152 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -75,8 +75,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod case .updatePresence(let presence): state.presence = presence } -// TODO: Uncomment when we have an abstract logger for RiotSwiftUI -// MXLog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") + UILog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") } private func done() { diff --git a/RiotSwiftUI/RiotSwiftUIApp.swift b/RiotSwiftUI/RiotSwiftUIApp.swift index 478d6f5d4..cd767602d 100644 --- a/RiotSwiftUI/RiotSwiftUIApp.swift +++ b/RiotSwiftUI/RiotSwiftUIApp.swift @@ -20,7 +20,10 @@ import SwiftUI */ @available(iOS 14.0, *) @main -struct testApp: App { +struct RiotSwiftUIApp: App { + init() { + UILog.configure(logger: PrintLogger.self) + } var body: some Scene { WindowGroup { Text("app") From 4fb59260d4fe98ac6a0151b2aa50d95d797b0c0f Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 10 Sep 2021 16:43:31 +0100 Subject: [PATCH 20/31] Add utility to run UI tests for Screen states, add screen states for template and finish unit test. --- RiotSwiftUI/Info.plist | 2 + .../Modules/Common/Mock/MockAppScreens.swift | 24 ++++++ .../Modules/Common/Mock/MockScreen.swift | 58 -------------- .../Modules/Common/Mock/MockScreenState.swift | 79 +++++++++++++++++++ .../Modules/Common/Mock/ScreenList.swift | 49 ++++++++++++ .../Modules/Common/Test/MockScreenTest.swift | 72 +++++++++++++++++ .../Mock/MockTemplateProfileUserScreen.swift | 45 ----------- .../MockTemplateProfileUserScreenState.swift | 60 ++++++++++++++ .../Test/UI/TestUserProfileUITests.swift | 46 +++++------ .../TemplateUserProfileViewModelTests.swift | 4 +- .../View/TemplateUserProfile.swift | 2 +- .../View/TemplateUserProfileHeader.swift | 3 + .../TemplateUserProfilePresenceView.swift | 1 + RiotSwiftUI/RiotSwiftUIApp.swift | 2 +- RiotSwiftUI/targetUITests.yml | 33 ++++---- 15 files changed, 332 insertions(+), 148 deletions(-) create mode 100644 RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift delete mode 100644 RiotSwiftUI/Modules/Common/Mock/MockScreen.swift create mode 100644 RiotSwiftUI/Modules/Common/Mock/MockScreenState.swift create mode 100644 RiotSwiftUI/Modules/Common/Mock/ScreenList.swift create mode 100644 RiotSwiftUI/Modules/Common/Test/MockScreenTest.swift delete mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreen.swift create mode 100644 RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift diff --git a/RiotSwiftUI/Info.plist b/RiotSwiftUI/Info.plist index c0701c6d7..0a5393324 100644 --- a/RiotSwiftUI/Info.plist +++ b/RiotSwiftUI/Info.plist @@ -2,6 +2,8 @@ + UILaunchScreen + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift new file mode 100644 index 000000000..a222edf3f --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -0,0 +1,24 @@ +// +// 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 + +/// The static list of mocked screens in RiotSwiftUI +@available(iOS 14.0, *) +enum MockAppScreens { + static let appScreens = [MockTemplateProfileUserScreenState.self] +} + diff --git a/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift b/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift deleted file mode 100644 index 55adc3cb7..000000000 --- a/RiotSwiftUI/Modules/Common/Mock/MockScreen.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI - -/* - Used for mocking top level screens and their various state. - */ -@available(iOS 14.0, *) -protocol MockScreen { - associatedtype ScreenType: View - static func screen(for state: Self) -> ScreenType - static var screenStates: [Self] { get } -} - - -@available(iOS 14.0, *) -extension MockScreen { - - /* - Get a list of the screens for every screen state. - */ - static var screens: [ScreenType] { - Self.screenStates.map(screen(for:)) - } - - /* - Render each of the screen states in a group applying - any optional environment variables. - */ - static func screenGroup( - themeId: ThemeIdentifier = .light, - locale: Locale = Locale.current, - sizeCategory: ContentSizeCategory = ContentSizeCategory.medium - ) -> some View { - Group { - ForEach(0.. some View { + Group { + ForEach(0.. some View { - let service: MockTemplateUserProfileService - switch state { - case .mockPresenceStates(let presence): - service = MockTemplateUserProfileService(presence: presence) - case .mockLongDisplayName(let displayName): - service = MockTemplateUserProfileService(displayName: displayName) - } - let viewModel = TemplateUserProfileViewModel(userService: service) - return TemplateUserProfile(viewModel: viewModel) - .addDependency(MockAvatarService.example) - } -} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift new file mode 100644 index 000000000..29092cd8d --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift @@ -0,0 +1,60 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockTemplateProfileUserScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case presence(TemplateUserProfilePresence) + case longDisplayName(String) + + /// The associated screen + var screenType: Any.Type { + TemplateUserProfile.self + } + + /// A list of screen state definitions + static var allCases: [MockTemplateProfileUserScreenState] { + // Each of the presence statuses + TemplateUserProfilePresence.allCases.map(MockTemplateProfileUserScreenState.presence) + // A long display name + + [.longDisplayName("Somebody with a super long name we would like to test")] + } + + /// Generate the view struct for the screen state. + var screenView: AnyView { + let service: MockTemplateUserProfileService + switch self { + case .presence(let presence): + service = MockTemplateUserProfileService(presence: presence) + case .longDisplayName(let displayName): + service = MockTemplateUserProfileService(displayName: displayName) + } + let viewModel = TemplateUserProfileViewModel(userService: service) + + // can simulate service and viewModel actions here if needs be. + + return AnyView(TemplateUserProfile(viewModel: viewModel) + .addDependency(MockAvatarService.example)) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift index 5ec87e61d..2bfd4d319 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift @@ -15,33 +15,35 @@ // import XCTest - -@testable import RiotSwiftUI +import RiotSwiftUI @available(iOS 14.0, *) -class TestUserProfileUITests: XCTestCase { - - let app = XCUIApplication() +class TestUserProfileUITests: MockScreenTest { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. - app.launch() - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + override class var screenType: MockScreenState.Type { + return MockTemplateProfileUserScreenState.self } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + + func testTemplateUserProfileScreen() throws { + guard let screenState = screenState as? MockTemplateProfileUserScreenState else { fatalError("no screen") } + switch screenState { + case .presence(let presence): + testTemplateUserProfilePresence(presence: presence) + case .longDisplayName(let name): + testTemplateUserProfileLongName(name: name) + } } - - func testUserContentTextDisplayed() throws { - let userContentText = app.staticTexts["More great user content!"] - XCTAssert(userContentText.exists) + + func testTemplateUserProfilePresence(presence: TemplateUserProfilePresence) { + let presenceText = app.staticTexts["presenceText"] + XCTAssert(presenceText.exists) + XCTAssert(presenceText.label == presence.title) + } + + func testTemplateUserProfileLongName(name: String) { + let displayNameText = app.staticTexts["displayNameText"] + XCTAssert(displayNameText.exists) + XCTAssert(displayNameText.label == name) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift index e7724c6d8..74925f5d7 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -38,12 +38,12 @@ class TemplateUserProfileViewModelTests: XCTestCase { XCTAssertEqual(viewModel.viewState.presence, Constants.presenceInitialValue) } - func testFirstPresenceRecieved() throws { + func testFirstPresenceReceived() throws { let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(1).first() XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) } - func testPresenceUpdatesRecieved() throws { + func testPresenceUpdatesReceived() throws { let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(3).first() let newPresenceValue1: TemplateUserProfilePresence = .online let newPresenceValue2: TemplateUserProfilePresence = .idle diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index fc6fc7e76..6025fceba 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -67,6 +67,6 @@ struct TemplateUserProfile: View { @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - MockTemplateProfileUserScreenStates.screenGroup() + MockTemplateProfileUserScreenState.screenGroup() } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift index d32111d27..1c169f506 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift @@ -38,6 +38,9 @@ struct TemplateUserProfileHeader: View { VStack(spacing: 8){ Text(displayName ?? "") .font(theme.fonts.title3) + .accessibility(identifier: "displayNameText") + .padding(.horizontal) + .lineLimit(1) TemplateUserProfilePresenceView(presence: presence) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift index 85b78ea26..3d724f540 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift @@ -31,6 +31,7 @@ struct TemplateUserProfilePresenceView: View { .frame(width: 8, height: 8) Text(presence.title) .font(.subheadline) + .accessibilityIdentifier("presenceText") } .foregroundColor(foregroundColor) .padding(0) diff --git a/RiotSwiftUI/RiotSwiftUIApp.swift b/RiotSwiftUI/RiotSwiftUIApp.swift index cd767602d..f62d72b44 100644 --- a/RiotSwiftUI/RiotSwiftUIApp.swift +++ b/RiotSwiftUI/RiotSwiftUIApp.swift @@ -26,7 +26,7 @@ struct RiotSwiftUIApp: App { } var body: some Scene { WindowGroup { - Text("app") + ScreenList(screens: MockAppScreens.appScreens) } } } diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index 187c7c635..4466226ad 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -33,30 +33,25 @@ targets: dependencies: - target: RiotSwiftUI -# configFiles: -# Debug: Debug.xcconfig -# Release: Release.xcconfig - settings: base: TEST_TARGET_NAME: RiotSwiftUI -# PRODUCT_NAME: RiotSwiftUITests PRODUCT_BUNDLE_IDENTIFIER: org.matrix.RiotSwiftUITests$(rfc1034identifier) -# BUNDLE_LOADER: $(TEST_HOST) -# FRAMEWORK_SEARCH_PATHS: $(SDKROOT)/Developer/Library/Frameworks $(inherited) -# INFOPLIST_FILE: RiotSwiftUI/Info.plist -# LD_RUNPATH_SEARCH_PATHS: $(inherited) @executable_path/Frameworks @loader_path/Frameworks -# PRODUCT_BUNDLE_IDENTIFIER: org.matrix.$(PRODUCT_NAME:rfc1034identifier) -# PRODUCT_NAME: RiotSwiftUITests -# TEST_TARGET_NAME: $(BUILT_PRODUCTS_DIR)/RiotSwiftUI.app/RiotSwiftUI -# configs: -# Debug: -# Release: -# PROVISIONING_PROFILE: $(RIOT_PROVISIONING_PROFILE) -# PROVISIONING_PROFILE_SPECIFIER: $(RIOT_PROVISIONING_PROFILE_SPECIFIER) sources: + # Source included/excluded here here are similar to RiotSwiftUI as we + # need access to ScreenStates - path: ../RiotSwiftUI/Modules - includes: - - "**/Test" excludes: + - "**/MatrixSDK/**" + - "**/Coordinator/**" - "**/Test/Unit/**" + - path: ../Riot/Generated/Strings.swift + - path: ../Riot/Generated/Images.swift + - path: ../Riot/Managers/Theme/ThemeIdentifier.swift + - path: ../Riot/Managers/Locale/LocaleProviderType.swift + - path: ../Riot/Assets/en.lproj/Vector.strings + buildPhase: resources + - path: ../Riot/Assets/Images.xcassets + buildPhase: resources + - path: ../Riot/Assets/SharedImages.xcassets + buildPhase: resources From bebdad3d5d86cd9306e1b684f8487c0e56128617 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 10 Sep 2021 16:55:11 +0100 Subject: [PATCH 21/31] fix example userId and avatarUrl --- .../Service/Mock/MockTemplateUserProfileService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 9a713fedc..29ef8d195 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -25,9 +25,9 @@ class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { var displayName: String? let avatarUrl: String? init( - userId: String = "123", + userId: String = "@alice:matrix.org", displayName: String? = "Alice", - avatarUrl: String? = "mx123@matrix.com", + avatarUrl: String? = "mxc://matrix.org/VyNYAgahaiAzUoOeZETtQ", presence: TemplateUserProfilePresence = .offline ) { self.userId = userId From 0b38e09c02af23e1e757ace656c9e2632b17fa11 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 10 Sep 2021 16:56:35 +0100 Subject: [PATCH 22/31] fix immutability --- .../Service/Mock/MockTemplateUserProfileService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 29ef8d195..0ce280d76 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -22,7 +22,7 @@ class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { var presenceSubject: CurrentValueSubject let userId: String - var displayName: String? + let displayName: String? let avatarUrl: String? init( userId: String = "@alice:matrix.org", From a20b50028e0a274c5dd428442507c453df8b435a Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 10 Sep 2021 17:02:20 +0100 Subject: [PATCH 23/31] move MockScreenTest to UI folder as should be excluded from Unit test target --- RiotSwiftUI/Modules/Common/Test/{ => UI}/MockScreenTest.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename RiotSwiftUI/Modules/Common/Test/{ => UI}/MockScreenTest.swift (100%) diff --git a/RiotSwiftUI/Modules/Common/Test/MockScreenTest.swift b/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift similarity index 100% rename from RiotSwiftUI/Modules/Common/Test/MockScreenTest.swift rename to RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift From 886bb98eb1032fdafe2e4196b71b0a5bdf431e67 Mon Sep 17 00:00:00 2001 From: David Langley Date: Sat, 11 Sep 2021 14:13:43 +0100 Subject: [PATCH 24/31] Fix Naming Change userService name to templateUserProfileService for templating. Remove test subclass from MockScreenTest --- .../Modules/Common/Mock/MockAppScreens.swift | 2 +- .../Modules/Common/Mock/ScreenList.swift | 2 +- .../Common/Test/UI/MockScreenTest.swift | 16 ++++++++------- .../TemplateUserProfileCoordinator.swift | 2 +- ... MockTemplateUserProfileScreenState.swift} | 8 ++++---- ...swift => TemplateUserProfileUITests.swift} | 20 +++++++++++-------- .../TemplateUserProfileViewModelTests.swift | 2 +- .../View/TemplateUserProfile.swift | 2 +- .../TemplateUserProfileViewModel.swift | 18 ++++++++++------- 9 files changed, 41 insertions(+), 31 deletions(-) rename RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/{MockTemplateProfileUserScreenState.swift => MockTemplateUserProfileScreenState.swift} (86%) rename RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/{TestUserProfileUITests.swift => TemplateUserProfileUITests.swift} (62%) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index a222edf3f..e028c5fb1 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -19,6 +19,6 @@ import Foundation /// The static list of mocked screens in RiotSwiftUI @available(iOS 14.0, *) enum MockAppScreens { - static let appScreens = [MockTemplateProfileUserScreenState.self] + static let appScreens = [MockTemplateUserProfileScreenState.self] } diff --git a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift index 62b857769..973ab530b 100644 --- a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift +++ b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift @@ -44,6 +44,6 @@ struct ScreenList: View { @available(iOS 14.0, *) struct ScreenList_Previews: PreviewProvider { static var previews: some View { - ScreenList(screens: [MockTemplateProfileUserScreenState.self]) + ScreenList(screens: [MockTemplateUserProfileScreenState.self]) } } diff --git a/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift b/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift index 9d3a65e6d..1146d4715 100644 --- a/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift +++ b/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift @@ -31,6 +31,10 @@ class MockScreenTest: XCTestCase { return nil } + class func createTest() -> MockScreenTest { + return MockScreenTest() + } + var screenState: MockScreenState? var screenStateKey: String? let app = XCUIApplication() @@ -48,13 +52,11 @@ class MockScreenTest: XCTestCase { return testSuite } - private class func addTestFor(screenState: MockScreenState, screenStateKey: String, toTestSuite testSuite: XCTestSuite) { - testInvocations.forEach { invocation in - let testCase = TestUserProfileUITests(invocation: invocation) - testCase.screenState = screenState - testCase.screenStateKey = screenStateKey - testSuite.addTest(testCase) - } + class func addTestFor(screenState: MockScreenState, screenStateKey: String, toTestSuite testSuite: XCTestSuite) { + let test = createTest() + test.screenState = screenState + test.screenStateKey = screenStateKey + testSuite.addTest(test) } open override func setUpWithError() throws { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index c42a297a7..5818b543b 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -39,7 +39,7 @@ final class TemplateUserProfileCoordinator: Coordinator { @available(iOS 14.0, *) init(parameters: TemplateUserProfileCoordinatorParameters) { self.parameters = parameters - let viewModel = TemplateUserProfileViewModel(userService: TemplateUserProfileService(session: parameters.session)) + let viewModel = TemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileService(session: parameters.session)) let view = TemplateUserProfile(viewModel: viewModel) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) templateUserProfileViewModel = viewModel diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift similarity index 86% rename from RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift index 29092cd8d..4388c4d1d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateProfileUserScreenState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift @@ -21,7 +21,7 @@ import SwiftUI /// Using an enum for the screen allows you define the different state cases with /// the relevant associated data for each case. @available(iOS 14.0, *) -enum MockTemplateProfileUserScreenState: MockScreenState, CaseIterable { +enum MockTemplateUserProfileScreenState: MockScreenState, CaseIterable { // A case for each state you want to represent // with specific, minimal associated data that will allow you // mock that screen. @@ -34,9 +34,9 @@ enum MockTemplateProfileUserScreenState: MockScreenState, CaseIterable { } /// A list of screen state definitions - static var allCases: [MockTemplateProfileUserScreenState] { + static var allCases: [MockTemplateUserProfileScreenState] { // Each of the presence statuses - TemplateUserProfilePresence.allCases.map(MockTemplateProfileUserScreenState.presence) + TemplateUserProfilePresence.allCases.map(MockTemplateUserProfileScreenState.presence) // A long display name + [.longDisplayName("Somebody with a super long name we would like to test")] } @@ -50,7 +50,7 @@ enum MockTemplateProfileUserScreenState: MockScreenState, CaseIterable { case .longDisplayName(let displayName): service = MockTemplateUserProfileService(displayName: displayName) } - let viewModel = TemplateUserProfileViewModel(userService: service) + let viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift similarity index 62% rename from RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift rename to RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift index 2bfd4d319..3507fcdf9 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TestUserProfileUITests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift @@ -18,29 +18,33 @@ import XCTest import RiotSwiftUI @available(iOS 14.0, *) -class TestUserProfileUITests: MockScreenTest { +class TemplateUserProfileUITests: MockScreenTest { override class var screenType: MockScreenState.Type { - return MockTemplateProfileUserScreenState.self + return MockTemplateUserProfileScreenState.self + } + + override class func createTest() -> MockScreenTest { + return TemplateUserProfileUITests(selector: #selector(verifyTemplateUserProfileScreen)) } - func testTemplateUserProfileScreen() throws { - guard let screenState = screenState as? MockTemplateProfileUserScreenState else { fatalError("no screen") } + func verifyTemplateUserProfileScreen() throws { + guard let screenState = screenState as? MockTemplateUserProfileScreenState else { fatalError("no screen") } switch screenState { case .presence(let presence): - testTemplateUserProfilePresence(presence: presence) + verifyTemplateUserProfilePresence(presence: presence) case .longDisplayName(let name): - testTemplateUserProfileLongName(name: name) + verifyTemplateUserProfileLongName(name: name) } } - func testTemplateUserProfilePresence(presence: TemplateUserProfilePresence) { + func verifyTemplateUserProfilePresence(presence: TemplateUserProfilePresence) { let presenceText = app.staticTexts["presenceText"] XCTAssert(presenceText.exists) XCTAssert(presenceText.label == presence.title) } - func testTemplateUserProfileLongName(name: String) { + func verifyTemplateUserProfileLongName(name: String) { let displayNameText = app.staticTexts["displayNameText"] XCTAssert(displayNameText.exists) XCTAssert(displayNameText.label == name) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift index 74925f5d7..f14b1a2e6 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -30,7 +30,7 @@ class TemplateUserProfileViewModelTests: XCTestCase { var cancellables = Set() override func setUpWithError() throws { service = MockTemplateUserProfileService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) - viewModel = TemplateUserProfileViewModel(userService: service) + viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) } func testInitialState() { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index 6025fceba..64cbf3ca4 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -67,6 +67,6 @@ struct TemplateUserProfile: View { @available(iOS 14.0, *) struct TemplateUserProfile_Previews: PreviewProvider { static var previews: some View { - MockTemplateProfileUserScreenState.screenGroup() + MockTemplateUserProfileScreenState.screenGroup() } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index cf625e152..f6b60cc3d 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -23,7 +23,7 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod // MARK: - Properties // MARK: Private - private let userService: TemplateUserProfileServiceProtocol + private let templateUserProfileService: TemplateUserProfileServiceProtocol private var cancellables = Set() // MARK: Public @@ -32,11 +32,11 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod var completion: ((TemplateUserProfileViewModelResult) -> Void)? // MARK: - Setup - init(userService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { - self.userService = userService - self.viewState = initialState ?? Self.defaultState(userService: userService) + init(templateUserProfileService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { + self.templateUserProfileService = templateUserProfileService + self.viewState = initialState ?? Self.defaultState(templateUserProfileService: templateUserProfileService) - userService.presenceSubject + templateUserProfileService.presenceSubject .map(TemplateUserProfileStateAction.updatePresence) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] action in @@ -45,8 +45,12 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod .store(in: &cancellables) } - private static func defaultState(userService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { - return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName, presence: userService.presenceSubject.value) + private static func defaultState(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { + return TemplateUserProfileViewState( + avatar: templateUserProfileService.avatarData, + displayName: templateUserProfileService.displayName, + presence: templateUserProfileService.presenceSubject.value + ) } // MARK: - Public From 3d65fbd48f8aa69ed77ee64c585b8f2aec6fd86d Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 13 Sep 2021 11:36:33 +0100 Subject: [PATCH 25/31] Update RiotSwiftUI symbols to triple slash documentation style with function annotations. --- .../ActivityIndicator/ActivityIndicator.swift | 4 +- .../ActivityIndicatorModifier.swift | 4 +- .../Common/Avatar/Model/Avatarable.swift | 11 ++--- .../Service/MatrixSDK/AvatarService.swift | 15 +++--- .../ViewModel/AvatarServiceProtocol.swift | 5 +- .../Avatar/ViewModel/AvatarViewModel.swift | 29 ++++++----- .../Common/Bridging/VectorContentView.swift | 7 ++- .../DependencyContainer.swift | 23 +++++---- .../DependencyContainerKey.swift | 21 ++++---- .../Common/DependencyInjection/Inject.swift | 10 ++-- .../DependencyInjection/Injectable.swift | 10 ++-- .../InjectableObject.swift | 5 +- .../Modules/Common/Extensions/Publisher.swift | 8 ++-- .../Common/Logging/LoggerProtocol.swift | 5 +- .../Modules/Common/Logging/PrintLogger.swift | 7 ++- .../Modules/Common/Logging/UILog.swift | 8 ++-- .../Test/XCTestPublisherExtensions.swift | 19 ++++---- .../Theme/ThemeIdentifierExtensions.swift | 4 +- .../Modules/Common/Theme/ThemeKey.swift | 16 +++---- .../Modules/Common/Theme/ThemePublisher.swift | 9 ++-- .../ViewFrameReader/ViewFrameReader.swift | 21 ++++---- .../MatrixSDK/MXNotificationPushRule.swift | 10 ++-- .../Model/NotificationActions.swift | 4 +- .../Model/NotificationIndex.swift | 19 ++++---- .../NotificationPushRuleDefinitions.swift | 9 ++-- .../Model/NotificationPushRuleIds.swift | 4 +- .../Model/NotificationSettingsScreen.swift | 8 +--- .../Model/NotificationStandardActions.swift | 7 ++- .../NotificationSettingsServiceType.swift | 48 +++++++------------ .../View/BorderedInputFieldStyle.swift | 8 ++-- .../Settings/Notifications/View/Chip.swift | 5 +- .../Settings/Notifications/View/Chips.swift | 4 +- .../Notifications/View/ChipsInput.swift | 7 +-- .../View/FormInputFieldStyle.swift | 4 +- .../View/NotificationSettings.swift | 9 ++-- .../View/NotificationSettingsKeywords.swift | 4 +- .../NotificationSettingsViewModel.swift | 13 ++--- .../TemplateUserProfileViewModel.swift | 16 ++++--- RiotSwiftUI/RiotSwiftUIApp.swift | 4 +- 39 files changed, 184 insertions(+), 240 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicator.swift b/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicator.swift index f1e8f3694..541bfbd60 100644 --- a/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicator.swift +++ b/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicator.swift @@ -16,10 +16,8 @@ import SwiftUI -/** - A visual cue to user that something is in progress. - */ @available(iOS 14.0, *) +/// A visual cue to user that something is in progress. struct ActivityIndicator: View { private enum Constants { diff --git a/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicatorModifier.swift b/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicatorModifier.swift index 405c26649..821c71ef0 100644 --- a/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicatorModifier.swift +++ b/RiotSwiftUI/Modules/Common/ActivityIndicator/ActivityIndicatorModifier.swift @@ -17,10 +17,8 @@ import Foundation import SwiftUI -/** - A modifier for showing the activity indcator centered over a view. - */ @available(iOS 14.0, *) +/// A modifier for showing the activity indicator centered over a view. struct ActivityIndicatorModifier: ViewModifier { var show: Bool diff --git a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift index 17a0ea1ee..4e5062b5f 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.swift @@ -16,12 +16,11 @@ import Foundation -/** - A protocol that any class or struct can conform to - so that it can easily produce avatar data. - E.g. MXRoom, MxUser can conform to this making it - easy to grab the avatar data for display. - */ +/// A protocol that any class or struct can conform to +/// so that it can easily produce avatar data. +/// +/// E.g. MXRoom, MxUser can conform to this making it +/// easy to grab the avatar data for display. protocol Avatarable: AvatarInputProtocol { } extension Avatarable { var avatarData: AvatarInput { diff --git a/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift index 4aa3fdb32..34b6db55a 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift @@ -41,14 +41,13 @@ class AvatarService: AvatarServiceProtocol { self.mediaManager = mediaManager } - /** - Given an mxContentUri, this function returns a Future of UIImage. - If possible it will retrieve the image from network or cache, otherwise it will error. - - - Parameter mxContentUri: matrix uri of the avatar to fetch - - Parameter avatarSize: The size of avatar to retrieve as defined in the DesignKit spec. - - Returns: A Future of UIImage that returns an error if it fails to fetch the image - */ + /// Given an mxContentUri, this function returns a Future of UIImage. + /// + /// If possible it will retrieve the image from network or cache, otherwise it will error. + /// - Parameters: + /// - mxContentUri: matrix uri of the avatar to fetch + /// - avatarSize: The size of avatar to retrieve as defined in the DesignKit spec. + /// - Returns: A Future of UIImage that returns an error if it fails to fetch the image. @available(iOS 14.0, *) func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future { diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift index 2d51df330..bc8283ee6 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift @@ -19,9 +19,8 @@ import DesignKit import Combine import UIKit -/** - Provides a simple api to retrieve and cache avatar images - */ + +/// Provides a simple api to retrieve and cache avatar images protocol AvatarServiceProtocol { @available(iOS 14.0, *) func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift index 1612da1c2..6808f0dd6 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -18,11 +18,8 @@ import Foundation import Combine import DesignKit -/** - Simple ViewModel that supports loading an avatar image of a particular size - as specified in DesignKit and delivering the UIImage to the UI if possible. - */ @available(iOS 14.0, *) +/// Simple ViewModel that supports loading an avatar image class AvatarViewModel: InjectableObject, ObservableObject { @Inject var avatarService: AvatarServiceProtocol @@ -31,6 +28,13 @@ class AvatarViewModel: InjectableObject, ObservableObject { private var cancellables = Set() + /// Load an avatar + /// - Parameters: + /// - mxContentUri: The matrix content URI of the avatar. + /// - matrixItemId: The id of the matrix item represented by the avatar. + /// - displayName: Display name of the avatar. + /// - colorCount: The count of total avatar colors used to generate the stable color index. + /// - avatarSize: The size of the avatar to fetch (as defined within DesignKit). func loadAvatar( mxContentUri: String?, matrixItemId: String, @@ -54,9 +58,9 @@ class AvatarViewModel: InjectableObject, ObservableObject { .store(in: &cancellables) } - /** - Get the first character of a string capialized or else an empty string. - */ + /// Get the first character of a string capialized or else an empty string. + /// - Parameter string: The input string to get the capitalized letter from. + /// - Returns: The capitalized first letter. private func firstCharacterCapitalized(_ string: String?) -> String { guard let character = string?.first else { return "" @@ -64,10 +68,13 @@ class AvatarViewModel: InjectableObject, ObservableObject { return String(character).capitalized } - /** - Provides the same color each time for a specified matrixId. - Same algorithm as in AvatarGenerator. - */ + /// Provides the same color each time for a specified matrixId + /// + /// Same algorithm as in AvatarGenerator. + /// - Parameters: + /// - matrixItemId: the matrix id used as input to create the stable index. + /// - colorCount: The number of total colors we want to index in to. + /// - Returns: The stable index. private func stableColorIndex(matrixItemId: String, colorCount: Int) -> Int { // Sum all characters let sum = matrixItemId.utf8 diff --git a/RiotSwiftUI/Modules/Common/Bridging/VectorContentView.swift b/RiotSwiftUI/Modules/Common/Bridging/VectorContentView.swift index 9dac8ec08..449042d78 100644 --- a/RiotSwiftUI/Modules/Common/Bridging/VectorContentView.swift +++ b/RiotSwiftUI/Modules/Common/Bridging/VectorContentView.swift @@ -16,10 +16,9 @@ import SwiftUI -/** - A Modifier to be called from the top-most SwiftUI view before being added to a HostViewController - Provides any app level configuration the SwiftUI hierarchy might need (E.g. to monitor theme changes). - */ +/// A Modifier to be called from the top-most SwiftUI view before being added to a HostViewController. +/// +/// Provides any app level configuration the SwiftUI hierarchy might need (E.g. to monitor theme changes). @available(iOS 14.0, *) struct VectorContentModifier: ViewModifier { diff --git a/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainer.swift b/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainer.swift index 36e8678f3..c3c0169fd 100644 --- a/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainer.swift +++ b/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainer.swift @@ -16,19 +16,18 @@ import Foundation -/** - Used for storing and resolving dependencies at runtime. - */ +/// Used for storing and resolving dependencies at runtime. struct DependencyContainer { // Stores the dependencies with type information removed. private var dependencyStore: [String: Any] = [:] - /** - Resolve a dependency by type. - Given a particlar `Type` (Inferred from return type), - generate a key and retrieve from storage. - */ + /// Resolve a dependency by type. + /// + /// Given a particular `Type` (Inferred from return type), + /// generate a key and retrieve from storage. + /// + /// - Returns: The resolved dependency. func resolve() -> T { let key = String(describing: T.self) guard let t = dependencyStore[key] as? T else { @@ -37,10 +36,10 @@ struct DependencyContainer { return t } - /** - Register a dependency. - Given a dependency, generate a key from it's `Type` and save in storage. - */ + /// Register a dependency. + /// + /// Given a dependency, generate a key from it's `Type` and save in storage. + /// - Parameter dependency: The dependency to register. mutating func register(dependency: T) { let key = String(describing: T.self) dependencyStore[key] = dependency diff --git a/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainerKey.swift b/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainerKey.swift index 79557e541..1bfbd48b5 100644 --- a/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainerKey.swift +++ b/RiotSwiftUI/Modules/Common/DependencyInjection/DependencyContainerKey.swift @@ -17,10 +17,10 @@ import Foundation import SwiftUI -/** - An Environment Key for retrieving runtime dependencies to be injected into `ObservableObjects` - that are owned by a View (i.e. `@StateObject`'s, such as ViewModels owned by the View). - */ +/// An Environment Key for retrieving runtime dependencies. +/// +/// Dependencies are to be injected into `ObservableObjects` +/// that are owned by a View (i.e. `@StateObject`'s, such as ViewModels owned by the View). private struct DependencyContainerKey: EnvironmentKey { static let defaultValue = DependencyContainer() } @@ -36,12 +36,13 @@ extension EnvironmentValues { @available(iOS 14.0, *) extension View { - /** - A modifier for adding a dependency to the SwiftUI view hierarchy's dependency container. - Important: When adding a dependency to cast it to the type in which it will be injected. - So if adding `MockDependency` but type at injection is `Dependency` remember to cast - to `Dependency` first. - */ + /// A modifier for adding a dependency to the SwiftUI view hierarchy's dependency container. + /// + /// Important: When adding a dependency to cast it to the type in which it will be injected. + /// So if adding `MockDependency` but type at injection is `Dependency` remember to cast + /// to `Dependency` first. + /// - Parameter dependency: The dependency to add. + /// - Returns: The wrapped view that now includes the dependency. func addDependency(_ dependency: T) -> some View { transformEnvironment(\.dependencies) { container in container.register(dependency: dependency) diff --git a/RiotSwiftUI/Modules/Common/DependencyInjection/Inject.swift b/RiotSwiftUI/Modules/Common/DependencyInjection/Inject.swift index ff9d69eab..e81457678 100644 --- a/RiotSwiftUI/Modules/Common/DependencyInjection/Inject.swift +++ b/RiotSwiftUI/Modules/Common/DependencyInjection/Inject.swift @@ -16,11 +16,11 @@ import Foundation -/** - A property wrapped used to inject from the dependency - container on the instance to instance properties. - E.g. ```@Inject var someClass: SomeClass``` - */ +/// A property wrapped used to inject from the dependency container on the instance, to instance properties. +/// +/// ``` +/// @Inject var someClass: SomeClass +/// ``` @propertyWrapper struct Inject { static subscript( diff --git a/RiotSwiftUI/Modules/Common/DependencyInjection/Injectable.swift b/RiotSwiftUI/Modules/Common/DependencyInjection/Injectable.swift index 27a861ba2..96e5eef64 100644 --- a/RiotSwiftUI/Modules/Common/DependencyInjection/Injectable.swift +++ b/RiotSwiftUI/Modules/Common/DependencyInjection/Injectable.swift @@ -16,18 +16,16 @@ import Foundation -/** - A protocol for classes that can be injected with a dependency container - */ +/// A protocol for classes that can be injected with a dependency container protocol Injectable: AnyObject { var dependencies: DependencyContainer! { get set } } extension Injectable { - /** - Used to inject the dependency container into an Injectable. - */ + + /// Used to inject the dependency container into an Injectable. + /// - Parameter dependencies: The `DependencyContainer` to inject. func inject(dependencies: DependencyContainer) { self.dependencies = dependencies } diff --git a/RiotSwiftUI/Modules/Common/DependencyInjection/InjectableObject.swift b/RiotSwiftUI/Modules/Common/DependencyInjection/InjectableObject.swift index de5d1ccd8..bf38a0707 100644 --- a/RiotSwiftUI/Modules/Common/DependencyInjection/InjectableObject.swift +++ b/RiotSwiftUI/Modules/Common/DependencyInjection/InjectableObject.swift @@ -16,10 +16,7 @@ import Foundation -/** - Class that can be extended and supports - injection and the `@Inject` property wrapper. - */ +/// Class that can be extended that supports injection and the `@Inject` property wrapper. open class InjectableObject: Injectable { var dependencies: DependencyContainer! } diff --git a/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift b/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift index 6bc37c9b2..40703befa 100644 --- a/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift +++ b/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift @@ -17,13 +17,11 @@ import Foundation import Combine -/** - Sams as `assign(to:on:)` but maintains a weak reference to object(Useful in cases where you want to pass self and not cause a retain cycle.) - - SeeAlso: - [assign(to:on:)](https://developer.apple.com/documentation/combine/just/assign(to:on:)) - */ @available(iOS 14.0, *) extension Publisher where Failure == Never { + /// Sams as `assign(to:on:)` but maintains a weak reference to object + /// + /// Useful in cases where you want to pass self and not cause a retain cycle. func weakAssign( to keyPath: ReferenceWritableKeyPath, on object: T diff --git a/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift index 8930f07c7..806f4b1c7 100644 --- a/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift +++ b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift @@ -16,10 +16,7 @@ import Foundation -/** - A logger protocol that enables confirming types - to be used with UILog. - */ +/// A logger protocol that enables conforming types to be used with UILog. protocol LoggerProtocol { static func verbose(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) static func debug(_ message: @autoclosure () -> Any, _ file: String, _ function: String, line: Int, context: Any?) diff --git a/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift b/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift index faf90b540..29bfc8421 100644 --- a/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift +++ b/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift @@ -16,10 +16,9 @@ import Foundation -/** - A logger for logging to `print`. - For use with UILog. - */ +/// A logger for logging to `print`. +/// +/// For use with UILog. class PrintLogger: LoggerProtocol { static func verbose(_ message: @autoclosure () -> Any, _ file: String = #file, _ function: String = #function, line: Int = #line, context: Any? = nil) { print(message()) diff --git a/RiotSwiftUI/Modules/Common/Logging/UILog.swift b/RiotSwiftUI/Modules/Common/Logging/UILog.swift index 9874be30c..75c3325af 100644 --- a/RiotSwiftUI/Modules/Common/Logging/UILog.swift +++ b/RiotSwiftUI/Modules/Common/Logging/UILog.swift @@ -15,10 +15,10 @@ // import Foundation -/* - A logger for use in different application targets that can be configured - at runtime with a suitable logger. - */ + +/// A logger for use in different application targets. +/// +/// It can be configured at runtime with a suitable logger. class UILog: LoggerProtocol { static var _logger: LoggerProtocol.Type? diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift index 06fcd96f7..4861d08ab 100644 --- a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -17,16 +17,19 @@ import XCTest import Combine -/** - XCTest utility to wait for results from publishers, so that the output can be used for assertions. - - ``` - let collectedEvents = somePublisher.collect(3).first() - XCTAssertEqual(try xcAwait(collectedEvents), [expected, values, here]) - ``` - */ @available(iOS 14.0, *) extension XCTestCase { + /// XCTest utility to wait for results from publishers, so that the output can be used for assertions. + /// + /// ``` + /// let collectedEvents = somePublisher.collect(3).first() + /// XCTAssertEqual(try xcAwait(collectedEvents), [expected, values, here]) + /// ``` + /// - Parameters: + /// - publisher: The publisher to wait on. + /// - timeout: A timeout after which we give up. + /// - Throws: If it can't get the unwrapped result. + /// - Returns: The unwrapped result. func xcAwait( _ publisher: T, timeout: TimeInterval = 10 diff --git a/RiotSwiftUI/Modules/Common/Theme/ThemeIdentifierExtensions.swift b/RiotSwiftUI/Modules/Common/Theme/ThemeIdentifierExtensions.swift index f9e6530ed..d3e3c6c4b 100644 --- a/RiotSwiftUI/Modules/Common/Theme/ThemeIdentifierExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Theme/ThemeIdentifierExtensions.swift @@ -17,13 +17,11 @@ import Foundation import DesignKit -/** - Extension to `ThemeIdentifier` for getting the SwiftUI theme. - */ @available(iOS 14.0, *) extension ThemeIdentifier { fileprivate static let defaultTheme = DefaultThemeSwiftUI() fileprivate static let darkTheme = DarkThemeSwiftUI() + /// Extension to `ThemeIdentifier` for getting the SwiftUI theme. public var themeSwiftUI: ThemeSwiftUI { switch self { case .light: diff --git a/RiotSwiftUI/Modules/Common/Theme/ThemeKey.swift b/RiotSwiftUI/Modules/Common/Theme/ThemeKey.swift index f1ea41cea..eb4de70c1 100644 --- a/RiotSwiftUI/Modules/Common/Theme/ThemeKey.swift +++ b/RiotSwiftUI/Modules/Common/Theme/ThemeKey.swift @@ -31,25 +31,21 @@ extension EnvironmentValues { } } -/** - A theme modifier for setting the theme for this view and all its descendants in the hierarchy. - - Parameters: - - theme: a Theme to be set as the environment value. - */ @available(iOS 14.0, *) extension View { + /// A theme modifier for setting the theme for this view and all its descendants in the hierarchy. + /// - Parameter theme: A theme to be set as the environment value. + /// - Returns: The target view with the theme applied. func theme(_ theme: ThemeSwiftUI) -> some View { environment(\.theme, theme) } } -/** - A theme modifier for setting the theme by id for this view and all its descendants in the hierarchy. - - Parameters: - - themeId: ThemeIdentifier of a theme to be set as the environment value. - */ @available(iOS 14.0, *) extension View { + /// A theme modifier for setting the theme by id for this view and all its descendants in the hierarchy. + /// - Parameter themeId: ThemeIdentifier of a theme to be set as the environment value. + /// - Returns: The target view with the theme applied. func theme(_ themeId: ThemeIdentifier) -> some View { return environment(\.theme, themeId.themeSwiftUI) } diff --git a/RiotSwiftUI/Modules/Common/Theme/ThemePublisher.swift b/RiotSwiftUI/Modules/Common/Theme/ThemePublisher.swift index c721d7bf1..be6ba9b38 100644 --- a/RiotSwiftUI/Modules/Common/Theme/ThemePublisher.swift +++ b/RiotSwiftUI/Modules/Common/Theme/ThemePublisher.swift @@ -17,11 +17,10 @@ import Foundation import Combine -/** - Provides the theme and theme updates to SwiftUI. - Replaces the old ThemeObserver. Riot app can push updates to this class - removing the dependency of this class on the `ThemeService`. - */ +/// Provides the theme and theme updates to SwiftUI. +/// +/// Replaces the old ThemeObserver. Riot app can push updates to this class +/// removing the dependency of this class on the `ThemeService`. @available(iOS 14.0, *) class ThemePublisher: ObservableObject { diff --git a/RiotSwiftUI/Modules/Common/ViewFrameReader/ViewFrameReader.swift b/RiotSwiftUI/Modules/Common/ViewFrameReader/ViewFrameReader.swift index 4beb8f731..74318a0e1 100644 --- a/RiotSwiftUI/Modules/Common/ViewFrameReader/ViewFrameReader.swift +++ b/RiotSwiftUI/Modules/Common/ViewFrameReader/ViewFrameReader.swift @@ -17,18 +17,15 @@ import Foundation import SwiftUI -/** - Used to calculate the frame of a view. Useful in situations as with `ZStack` where - you might want to layout views using alignment guides. - Example usage: - ``` - @State private var frame: CGRect = CGRect.zero - ... - SomeView() - .background(ViewFrameReader(frame: $frame)) - - ``` - */ +/// Used to calculate the frame of a view. +/// +/// Useful in situations as with `ZStack` where you might want to layout views using alignment guides. +/// ``` +/// @State private var frame: CGRect = CGRect.zero +/// ... +/// SomeView() +/// .background(ViewFrameReader(frame: $frame)) +/// ``` @available(iOS 14.0, *) struct ViewFrameReader: View { @Binding var frame: CGRect diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift index 7da294aa0..6c67350af 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/MatrixSDK/MXNotificationPushRule.swift @@ -17,14 +17,12 @@ import Foundation import DesignKit -/** - Conformance of MXPushRule to the abstraction `NotificationPushRule` for use in `NotificationSettingsViewModel`. - */ +// Conformance of MXPushRule to the abstraction `NotificationPushRule` for use in `NotificationSettingsViewModel`. extension MXPushRule: NotificationPushRuleType { - /* - Given a rule, check it match the actions in the static definition. - */ + /// Given a rule, check it match the actions in the static definition. + /// - Parameter standardActions: The standard actions to match against. + /// - Returns: Wether `this` rule matches the standard actions. func matches(standardActions: NotificationStandardActions?) -> Bool { guard let standardActions = standardActions else { return false diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationActions.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationActions.swift index 519c71116..2facda9cd 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationActions.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationActions.swift @@ -16,9 +16,7 @@ import Foundation -/** - The actions defined on a push rule, used in the static push rule definitions. - */ +/// The actions defined on a push rule, used in the static push rule definitions. struct NotificationActions { let notify: Bool let highlight: Bool diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationIndex.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationIndex.swift index 6b562f5e7..89088159a 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationIndex.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationIndex.swift @@ -16,11 +16,10 @@ import Foundation -/** - Index that determines the state of the push setting. - Silent case is un-unsed on iOS but keeping in for consistency of - definition across the platforms. - */ +/// Index that determines the state of the push setting. +/// +/// Silent case is un-used on iOS but keeping in for consistency of +/// definition across the platforms. enum NotificationIndex { case off case silent @@ -30,16 +29,14 @@ enum NotificationIndex { extension NotificationIndex: CaseIterable { } extension NotificationIndex { - /** - Used to map the on/off checkmarks to an index used in the static push rule definitions. - */ + /// Used to map the on/off checkmarks to an index used in the static push rule definitions. + /// - Parameter enabled: Enabled/Disabled state. + /// - Returns: The associated NotificationIndex static func index(when enabled: Bool) -> NotificationIndex { return enabled ? .noisy : .off } - /** - Used to map from the checked state back to the index. - */ + /// Used to map from the checked state back to the index. var enabled: Bool { return self != .off } diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleDefinitions.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleDefinitions.swift index 35907875c..10fa5ec90 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleDefinitions.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleDefinitions.swift @@ -18,10 +18,11 @@ import Foundation extension NotificationPushRuleId { - /** - A static definition of the push rule actions. - It is defined similarly across Web and Android. - */ + /// A static definition of the push rule actions. + /// + /// It is defined similarly across Web and Android. + /// - Parameter index: The notification index for which to get the actions for. + /// - Returns: The associated `NotificationStandardActions`. func standardActions(for index: NotificationIndex) -> NotificationStandardActions? { switch self { case .containDisplayName: diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift index b3af72eca..38ed2b521 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationPushRuleIds.swift @@ -16,9 +16,7 @@ import Foundation -/** - The push rule ids used in notification settings and the static rule definitions. - */ +/// The push rule ids used in notification settings and the static rule definitions. enum NotificationPushRuleId: String { case suppressBots = ".m.rule.suppress_notices" case inviteMe = ".m.rule.invite_for_me" diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift index 63cfa7a91..7049e67bd 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationSettingsScreen.swift @@ -16,9 +16,7 @@ import Foundation -/** -The notification settings screen definitions, used when calling the coordinator. - */ +/// The notification settings screen definitions, used when calling the coordinator. @objc enum NotificationSettingsScreen: Int { case defaultNotifications case mentionsAndKeywords @@ -32,9 +30,7 @@ extension NotificationSettingsScreen: Identifiable { } extension NotificationSettingsScreen { - /** - Defines which rules are handled by each of the screens. - */ + /// Defines which rules are handled by each of the screens. var pushRules: [NotificationPushRuleId] { switch self { case .defaultNotifications: diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationStandardActions.swift b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationStandardActions.swift index 7bc3ec471..466b11595 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationStandardActions.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Model/NotificationStandardActions.swift @@ -16,10 +16,9 @@ import Foundation -/** - A static definition of the different actions that can be defined on push rules. - It is defined similarly across Web and Android. - */ +/// A static definition of the different actions that can be defined on push rules. +/// +/// It is defined similarly across Web and Android. enum NotificationStandardActions { case notify case notifyDefaultSound diff --git a/RiotSwiftUI/Modules/Settings/Notifications/Service/NotificationSettingsServiceType.swift b/RiotSwiftUI/Modules/Settings/Notifications/Service/NotificationSettingsServiceType.swift index 905b2eda2..317cc8253 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/Service/NotificationSettingsServiceType.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/Service/NotificationSettingsServiceType.swift @@ -17,41 +17,29 @@ import Foundation import Combine -/** - A service for changing notification settings and keywords - */ +/// A service for changing notification settings and keywords @available(iOS 14.0, *) protocol NotificationSettingsServiceType { - /** - Publisher of all push rules. - */ + /// Publisher of all push rules. var rulesPublisher: AnyPublisher<[NotificationPushRuleType], Never> { get } - /** - Publisher of content rules. - */ + + /// Publisher of content rules. var contentRulesPublisher: AnyPublisher<[NotificationPushRuleType], Never> { get } - /** - Adds a keyword. - - - Parameters: - - keyword: The keyword to add. - - enabled: Whether the keyword should be added in the enabled or disabled state. - */ + + /// Adds a keyword. + /// - Parameters: + /// - keyword: The keyword to add. + /// - enabled: Whether the keyword should be added in the enabled or disabled state. func add(keyword: String, enabled: Bool) - /** - Removes a keyword. - - - Parameters: - - keyword: The keyword to remove. - */ + + /// Removes a keyword. + /// - Parameter keyword: The keyword to remove. func remove(keyword: String) - /** - Updates the push rule actions. - - - Parameters: - - ruleId: The id of the rule. - - enabled: Whether the rule should be enabled or disabled. - - actions: The actions to update with. - */ + + /// Updates the push rule actions. + /// - Parameters: + /// - ruleId: The id of the rule. + /// - enabled: Whether the rule should be enabled or disabled. + /// - actions: The actions to update with. func updatePushRuleActions(for ruleId: String, enabled: Bool, actions: NotificationActions?) } diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/BorderedInputFieldStyle.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/BorderedInputFieldStyle.swift index 24fcc06f1..89e4349fd 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/BorderedInputFieldStyle.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/BorderedInputFieldStyle.swift @@ -17,11 +17,11 @@ import Foundation import SwiftUI -/** - A bordered style of text input as defined in: - https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=2039%3A26415 - */ @available(iOS 14.0, *) +/// A bordered style of text input +/// +/// As defined in: +/// https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=2039%3A26415 struct BorderedInputFieldStyle: TextFieldStyle { @Environment(\.theme) var theme: ThemeSwiftUI diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/Chip.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/Chip.swift index 5ec26ef70..458293f6c 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/Chip.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/Chip.swift @@ -16,9 +16,8 @@ import SwiftUI -/** - A single rounded rect chip to be rendered within `Chips` collection - */ + +/// A single rounded rect chip to be rendered within `Chips` collection @available(iOS 14.0, *) struct Chip: View { diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/Chips.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/Chips.swift index 8b729ae8b..0c3c8bfe7 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/Chips.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/Chips.swift @@ -16,9 +16,7 @@ import SwiftUI -/** - Renders multiple chips in a flow layout. - */ +/// Renders multiple chips in a flow layout. @available(iOS 14.0, *) struct Chips: View { diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/ChipsInput.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/ChipsInput.swift index 7d969403c..10a82add6 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/ChipsInput.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/ChipsInput.swift @@ -16,11 +16,7 @@ import SwiftUI - -/** - Renders an input field and a collection of chips - with callbacks for addition and deletion. - */ +/// Renders an input field and a collection of chips. @available(iOS 14.0, *) struct ChipsInput: View { @@ -29,7 +25,6 @@ struct ChipsInput: View { @State private var chipText: String = "" - let titles: [String] let didAddChip: (String) -> Void let didDeleteChip: (String) -> Void diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/FormInputFieldStyle.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/FormInputFieldStyle.swift index cc10f9591..9f7ccf7ff 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/FormInputFieldStyle.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/FormInputFieldStyle.swift @@ -17,9 +17,7 @@ import Foundation import SwiftUI -/** - An input field for forms. - */ +/// An input field style for forms. @available(iOS 14.0, *) struct FormInputFieldStyle: TextFieldStyle { diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettings.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettings.swift index b72f477e4..8a461d07d 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettings.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettings.swift @@ -16,11 +16,10 @@ import SwiftUI -/** - Renders the push rule settings that can be enabled/disable. - Also renders an optional bottom section - (used in the case of keywords, for the keyword chips and input). - */ +/// Renders the push rule settings that can be enabled/disable. +/// +/// Also renders an optional bottom section. +/// Used in the case of keywords, for the keyword chips and input. @available(iOS 14.0, *) struct NotificationSettings: View { diff --git a/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettingsKeywords.swift b/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettingsKeywords.swift index 7e6b4aa72..460eed436 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettingsKeywords.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/View/NotificationSettingsKeywords.swift @@ -16,9 +16,7 @@ import SwiftUI -/** - Renders the keywords input, driven by 'NotificationSettingsViewModel'. - */ +/// Renders the keywords input, driven by 'NotificationSettingsViewModel'. @available(iOS 14.0, *) struct NotificationSettingsKeywords: View { @ObservedObject var viewModel: NotificationSettingsViewModel diff --git a/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift b/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift index 20194e237..90b8ac38f 100644 --- a/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift +++ b/RiotSwiftUI/Modules/Settings/Notifications/ViewModel/NotificationSettingsViewModel.swift @@ -168,12 +168,13 @@ final class NotificationSettingsViewModel: NotificationSettingsViewModelType, Ob self.viewState.selectionState[.keywords] = anyEnabled } } - - /** - Given a push rule check which index/checked state it matches. - Matcing is done by comparing the rule against the static definitions for that rule. - The same logic is used on android. - */ + + /// Given a push rule check which index/checked state it matches. + /// + /// Matching is done by comparing the rule against the static definitions for that rule. + /// The same logic is used on android. + /// - Parameter rule: The push rule type to check. + /// - Returns: Wether it should be displayed as checked or not checked. private func isChecked(rule: NotificationPushRuleType) -> Bool { guard let ruleId = NotificationPushRuleId(rawValue: rule.ruleId) else { return false } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index f6b60cc3d..ca1644b79 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -64,16 +64,18 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod } // MARK: - Private - /** - Send state actions to mutate the state. - */ + /// Send state actions to mutate the state. + /// - Parameter action: The `TemplateUserProfileStateAction` to trigger the state change. private func dispatch(action: TemplateUserProfileStateAction) { Self.reducer(state: &self.viewState, action: action) } - - /** - A redux style reducer, all modifications to state happen here. Receives a state and a state action and produces a new state. - */ + + /// A redux style reducer + /// + /// All modifications to state happen here. + /// - Parameters: + /// - state: The `inout` state to be modified, + /// - action: The action that defines which state modification should take place. private static func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { switch action { case .updatePresence(let presence): diff --git a/RiotSwiftUI/RiotSwiftUIApp.swift b/RiotSwiftUI/RiotSwiftUIApp.swift index f62d72b44..63dbece0d 100644 --- a/RiotSwiftUI/RiotSwiftUIApp.swift +++ b/RiotSwiftUI/RiotSwiftUIApp.swift @@ -15,11 +15,9 @@ // import SwiftUI -/** - RiotSwiftUI screens rendered for UI Tests. - */ @available(iOS 14.0, *) @main +/// RiotSwiftUI screens rendered for UI Tests. struct RiotSwiftUIApp: App { init() { UILog.configure(logger: PrintLogger.self) From da5fcd5d4fff546787a8c4bdf32c8a9aac2971b9 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 14 Sep 2021 22:28:25 +0100 Subject: [PATCH 26/31] Add StateStoreViewModel and publisher extensions for convenienec. --- .../StateStorePublisherExtensions.swift | 26 ++++++ .../ViewModel/StateStoreViewModel.swift | 93 +++++++++++++++++++ .../TemplateUserProfileCoordinator.swift | 4 +- .../Model/TemplateUserProfileViewState.swift | 2 +- .../MockTemplateUserProfileScreenState.swift | 4 +- .../View/TemplateUserProfile.swift | 7 +- .../TemplateUserProfileViewModel.swift | 49 +++++----- ...TemplateUserProfileViewModelProtocol.swift | 5 + 8 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift create mode 100644 RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift diff --git a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift new file mode 100644 index 000000000..a5332c1fc --- /dev/null +++ b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift @@ -0,0 +1,26 @@ +import Foundation +import Combine + +@available(iOS 14.0, *) +extension Publisher { + + func sinkDispatchTo(_ store: StateStoreViewModel) where SA == Output, Failure == Never { + return self + .subscribe(on: DispatchQueue.main) + .sink { [weak store] (output) in + guard let store = store else { return } + store.dispatch(action: output) + } + .store(in: &store.cancellables) + } + + + func dispatchTo(_ store: StateStoreViewModel) -> Publishers.HandleEvents> where SA == Output, Failure == Never { + return self + .subscribe(on: DispatchQueue.main) + .handleEvents(receiveOutput: { [weak store] action in + guard let store = store else { return } + store.dispatch(action: action) + }) + } +} diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift new file mode 100644 index 000000000..78ffe52d0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -0,0 +1,93 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +import Foundation +import Combine + +protocol BindableState { + associatedtype BindStateType = Void + var bindings: BindStateType { get set } +} + +extension BindableState where BindStateType == Void { + var bindings: Void { + get { + () + } + set { + fatalError("Can't bind to the default Void binding.") + } + } +} + +@available(iOS 14, *) +class ViewModelContext: ObservableObject { + + private var cancellables = Set() + + let inputActions: PassthroughSubject + @Published var inputState: ViewState.BindStateType + @Published fileprivate(set) var viewState: ViewState + + init(initialViewState: ViewState) { + self.inputState = initialViewState.bindings + self.inputActions = PassthroughSubject() + self.viewState = initialViewState + if !(initialViewState.bindings is Void) { + self.$inputState + .weakAssign(to: \.viewState.bindings, on: self) + .store(in: &cancellables) + } + } +} + +@available(iOS 14, *) +class StateStoreViewModel { + + typealias Context = ViewModelContext + + let state: CurrentValueSubject + + var cancellables = Set() + var context: Context + + init(initialViewState: State) { + + self.context = Context(initialViewState: initialViewState) + self.state = CurrentValueSubject(initialViewState) + self.state.weakAssign(to: \.context.viewState, on: self) + .store(in: &cancellables) + self.context.inputActions.sink { [weak self] action in + guard let self = self else { return } + self.process(viewAction: action) + } + .store(in: &cancellables) + } + func dispatch(action: StateAction) { + reducer(state: &state.value, action: action) + } + + func reducer(state: inout State, action: StateAction) { + + } + + func process(viewAction: ViewAction) { + + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 5818b543b..6d4a8780c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -39,8 +39,8 @@ final class TemplateUserProfileCoordinator: Coordinator { @available(iOS 14.0, *) init(parameters: TemplateUserProfileCoordinatorParameters) { self.parameters = parameters - let viewModel = TemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileService(session: parameters.session)) - let view = TemplateUserProfile(viewModel: viewModel) + let viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileService(session: parameters.session)) + let view = TemplateUserProfile(viewModel: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) templateUserProfileViewModel = viewModel templateUserProfileHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift index 1634b8c1d..e7e7b9317 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift @@ -16,7 +16,7 @@ import Foundation -struct TemplateUserProfileViewState { +struct TemplateUserProfileViewState: BindableState { let avatar: AvatarInputProtocol? let displayName: String? var presence: TemplateUserProfilePresence diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift index 4388c4d1d..549716975 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift @@ -50,11 +50,11 @@ enum MockTemplateUserProfileScreenState: MockScreenState, CaseIterable { case .longDisplayName(let displayName): service = MockTemplateUserProfileService(displayName: displayName) } - let viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) + let viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: service) // can simulate service and viewModel actions here if needs be. - return AnyView(TemplateUserProfile(viewModel: viewModel) + return AnyView(TemplateUserProfile(viewModel: viewModel.context) .addDependency(MockAvatarService.example)) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index 64cbf3ca4..ebde27b1c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -27,9 +27,10 @@ struct TemplateUserProfile: View { // MARK: Public - @ObservedObject var viewModel: TemplateUserProfileViewModel + @ObservedObject var viewModel: TemplateUserProfileViewModel.Context var body: some View { + EmptyView() VStack { TemplateUserProfileHeader( avatar: viewModel.viewState.avatar, @@ -50,12 +51,12 @@ struct TemplateUserProfile: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button(VectorL10n.done) { - viewModel.process(viewAction: .cancel) + viewModel.inputActions.send(.done) } } ToolbarItem(placement: .cancellationAction) { Button(VectorL10n.cancel) { - viewModel.process(viewAction: .cancel) + viewModel.inputActions.send(.cancel) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index ca1644b79..5b781c494 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -16,33 +16,36 @@ import SwiftUI import Combine - -@available(iOS 14.0, *) -class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewModelProtocol { + + + +@available(iOS 14, *) +typealias TemplateUserProfileViewModelType = StateStoreViewModel +@available(iOS 14, *) +class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUserProfileViewModelProtocol { // MARK: - Properties // MARK: Private + private let templateUserProfileService: TemplateUserProfileServiceProtocol - private var cancellables = Set() // MARK: Public - @Published private(set) var viewState: TemplateUserProfileViewState var completion: ((TemplateUserProfileViewModelResult) -> Void)? // MARK: - Setup - init(templateUserProfileService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { + + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol { + return TemplateUserProfileViewModel(templateUserProfileService: templateUserProfileService) + } + + fileprivate init(templateUserProfileService: TemplateUserProfileServiceProtocol) { self.templateUserProfileService = templateUserProfileService - self.viewState = initialState ?? Self.defaultState(templateUserProfileService: templateUserProfileService) - - templateUserProfileService.presenceSubject - .map(TemplateUserProfileStateAction.updatePresence) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] action in - self?.dispatch(action:action) - }) - .store(in: &cancellables) + super.init(initialViewState: Self.defaultState(templateUserProfileService: templateUserProfileService)) + setupPresenceObserving() } private static func defaultState(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { @@ -53,8 +56,15 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod ) } + private func setupPresenceObserving() { + templateUserProfileService.presenceSubject + .map(TemplateUserProfileStateAction.updatePresence) + .sinkDispatchTo(self) + } + // MARK: - Public - func process(viewAction: TemplateUserProfileViewAction) { + + override func process(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: cancel() @@ -62,13 +72,6 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod done() } } - - // MARK: - Private - /// Send state actions to mutate the state. - /// - Parameter action: The `TemplateUserProfileStateAction` to trigger the state change. - private func dispatch(action: TemplateUserProfileStateAction) { - Self.reducer(state: &self.viewState, action: action) - } /// A redux style reducer /// diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift index 4f038ae32..271ec3c38 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift @@ -17,5 +17,10 @@ import Foundation protocol TemplateUserProfileViewModelProtocol { + var completion: ((TemplateUserProfileViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol + @available(iOS 14, *) + var context: TemplateUserProfileViewModelType.Context { get } } From e01fd46b2eef9933a33f596a50a5ea27d7349369 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 15 Sep 2021 14:04:18 +0100 Subject: [PATCH 27/31] Improve StateStore documentation and naming. --- .../Common/ViewModel/BindableState.swift | 37 ++++++ .../ViewModel/StateStoreViewModel.swift | 108 +++++++++++++----- .../View/TemplateUserProfile.swift | 4 +- .../TemplateUserProfileViewModel.swift | 12 +- 4 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 RiotSwiftUI/Modules/Common/ViewModel/BindableState.swift diff --git a/RiotSwiftUI/Modules/Common/ViewModel/BindableState.swift b/RiotSwiftUI/Modules/Common/ViewModel/BindableState.swift new file mode 100644 index 000000000..79e658af4 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/ViewModel/BindableState.swift @@ -0,0 +1,37 @@ +// +// 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 + +/// Represents a specific portion of the ViewState that can be bound to with SwiftUI's [2-way binding](https://developer.apple.com/documentation/swiftui/binding). +protocol BindableState { + /// The associated type of the Bindable State. Defaults to Void. + associatedtype BindStateType = Void + var bindings: BindStateType { get set } +} + +extension BindableState where BindStateType == Void { + /// We provide a default implementation for the Void type so that we can have `ViewState` that + /// just doesn't include/take advantage of the bindings. + var bindings: Void { + get { + () + } + set { + fatalError("Can't bind to the default Void binding.") + } + } +} diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift index 78ffe52d0..4bddd9340 100644 --- a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -20,74 +20,120 @@ import Combine import Foundation import Combine -protocol BindableState { - associatedtype BindStateType = Void - var bindings: BindStateType { get set } -} - -extension BindableState where BindStateType == Void { - var bindings: Void { - get { - () - } - set { - fatalError("Can't bind to the default Void binding.") - } - } -} +/// A constrained and concise interface for interacting with the ViewModel. +/// +/// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact +/// ViewModel (as modelled on our previous template architecture with the addition of two-way binding): +/// - The ability read/observe view state +/// - The ability to send view events +/// - The ability to bind state to a specific portion of the view state safely. +/// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published` +/// properties which which are property wrappers and therefore can't be defined within protocols. +/// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback). +/// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks +/// can't be made into the `ViewModel`. @available(iOS 14, *) class ViewModelContext: ObservableObject { - - private var cancellables = Set() + // MARK: - Properties - let inputActions: PassthroughSubject - @Published var inputState: ViewState.BindStateType + // MARK: Private + + private var cancellables = Set() + fileprivate let viewActions: PassthroughSubject + + // MARK: Public + + /// Set-able/Bindable `Published` property for the bindable portion of the `ViewState` + @Published var bindings: ViewState.BindStateType + /// Get-able/Observable `Published` property for the `ViewState` @Published fileprivate(set) var viewState: ViewState + // MARK: Setup + init(initialViewState: ViewState) { - self.inputState = initialViewState.bindings - self.inputActions = PassthroughSubject() + self.bindings = initialViewState.bindings + self.viewActions = PassthroughSubject() self.viewState = initialViewState if !(initialViewState.bindings is Void) { - self.$inputState + // If we have bindable state defined, forward its updates on to the `ViewState` + self.$bindings .weakAssign(to: \.viewState.bindings, on: self) .store(in: &cancellables) } } + + // MARK: Public + + /// Send a `ViewAction` to the `ViewModel` for processing. + /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. + func send(viewAction: ViewAction) { + viewActions.send(viewAction) + } } @available(iOS 14, *) -class StateStoreViewModel { +/// A common ViewModel implementation for handling of `State`, `StateAction`s and `ViewAction`s +/// +/// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) +/// a specific portion of state that can be safely bound to. +/// If we decide to add more features to our state management (like doing state processing off the main thread) +/// we can do it in this centralised place. +class StateStoreViewModel { typealias Context = ViewModelContext - let state: CurrentValueSubject + // MARK: - Properties + + // MARK: Private + + private let state: CurrentValueSubject + // MARK: Public + + /// For storing subscription references. + /// + /// Left as public for `ViewModel` implementations convenience. var cancellables = Set() + + /// Constrained interface for passing to Views. var context: Context + // MARK: Setup + init(initialViewState: State) { - self.context = Context(initialViewState: initialViewState) self.state = CurrentValueSubject(initialViewState) + // Connect the state to context viewState, that view uses for observing (but not modifying directly) the state. self.state.weakAssign(to: \.context.viewState, on: self) .store(in: &cancellables) - self.context.inputActions.sink { [weak self] action in + // Receive events from the view and pass on to the `ViewModel` for processing. + self.context.viewActions.sink { [weak self] action in guard let self = self else { return } self.process(viewAction: action) } .store(in: &cancellables) } + + /// Send state actions to modify the state within the reducer. + /// - Parameter action: The state action to send to the reducer. func dispatch(action: StateAction) { - reducer(state: &state.value, action: action) + Self.reducer(state: &state.value, action: action) } - func reducer(state: inout State, action: StateAction) { - + /// Override to handle mutations to the `State` + /// + /// A redux style reducer, all modifications to state happen here. + /// - Parameters: + /// - state: The `inout` state to be modified, + /// - action: The action that defines which state modification should take place. + class func reducer(state: inout State, action: StateAction) { + //Default implementation, -no-op } - + + /// Override to handles incoming `ViewAction`s from the `ViewModel`. + /// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation. func process(viewAction: ViewAction) { - + //Default implementation, -no-op } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index ebde27b1c..30902c482 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -51,12 +51,12 @@ struct TemplateUserProfile: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button(VectorL10n.done) { - viewModel.inputActions.send(.done) + viewModel.send(viewAction: .done) } } ToolbarItem(placement: .cancellationAction) { Button(VectorL10n.cancel) { - viewModel.inputActions.send(.cancel) + viewModel.send(viewAction: .cancel) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 5b781c494..82834b977 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -37,7 +37,7 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs var completion: ((TemplateUserProfileViewModelResult) -> Void)? // MARK: - Setup - + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol { return TemplateUserProfileViewModel(templateUserProfileService: templateUserProfileService) } @@ -72,14 +72,8 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs done() } } - - /// A redux style reducer - /// - /// All modifications to state happen here. - /// - Parameters: - /// - state: The `inout` state to be modified, - /// - action: The action that defines which state modification should take place. - private static func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { + + override class func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { switch action { case .updatePresence(let presence): state.presence = presence From cda4a354d1e8dc2c7d1a75d509d9e3d83b6842dc Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 15 Sep 2021 16:09:41 +0100 Subject: [PATCH 28/31] Allow defer in xcAwait. simplify sending state actions from a publisher. Fix tests. --- .../StateStorePublisherExtensions.swift | 26 -------------- .../Test/XCTestPublisherExtensions.swift | 35 +++++++++++++++---- .../ViewModel/StateStoreViewModel.swift | 15 ++++++-- .../Mock/MockTemplateUserProfileService.swift | 2 +- .../TemplateUserProfileViewModelTests.swift | 17 +++++---- .../TemplateUserProfileViewModel.swift | 5 +-- 6 files changed, 55 insertions(+), 45 deletions(-) delete mode 100644 RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift diff --git a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift deleted file mode 100644 index a5332c1fc..000000000 --- a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import Combine - -@available(iOS 14.0, *) -extension Publisher { - - func sinkDispatchTo(_ store: StateStoreViewModel) where SA == Output, Failure == Never { - return self - .subscribe(on: DispatchQueue.main) - .sink { [weak store] (output) in - guard let store = store else { return } - store.dispatch(action: output) - } - .store(in: &store.cancellables) - } - - - func dispatchTo(_ store: StateStoreViewModel) -> Publishers.HandleEvents> where SA == Output, Failure == Never { - return self - .subscribe(on: DispatchQueue.main) - .handleEvents(receiveOutput: { [weak store] action in - guard let store = store else { return } - store.dispatch(action: action) - }) - } -} diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift index 4861d08ab..7c0f2ec72 100644 --- a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -34,6 +34,25 @@ extension XCTestCase { _ publisher: T, timeout: TimeInterval = 10 ) throws -> T.Output { + return try xcAwaitDeferred(publisher, timeout: timeout)() + } + + /// XCTest utility that allows for a deferred wait of results from publishers, so that the output can be used for assertions. + /// + /// ``` + /// let collectedEvents = somePublisher.collect(3).first() + /// let awaitDeferred = xcAwaitDeferred(collectedEvents) + /// // Do some other work that publishes to somePublisher + /// XCTAssertEqual(try awaitDeferred(), [expected, values, here]) + /// ``` + /// - Parameters: + /// - publisher: The publisher to wait on. + /// - timeout: A timeout after which we give up. + /// - Returns: A closure that starts the waiting of results when called. The closure will return the unwrapped result. + func xcAwaitDeferred( + _ publisher: T, + timeout: TimeInterval = 10 + ) -> (() throws -> (T.Output)) { var result: Result? let expectation = self.expectation(description: "Awaiting publisher") @@ -52,12 +71,14 @@ extension XCTestCase { result = .success(value) } ) - waitForExpectations(timeout: timeout) - cancellable.cancel() - let unwrappedResult = try XCTUnwrap( - result, - "Awaited publisher did not produce any output" - ) - return try unwrappedResult.get() + return { + self.waitForExpectations(timeout: timeout) + cancellable.cancel() + let unwrappedResult = try XCTUnwrap( + result, + "Awaited publisher did not produce any output" + ) + return try unwrappedResult.get() + } } } diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift index 4bddd9340..2cc2b43d5 100644 --- a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -72,13 +72,13 @@ class ViewModelContext: ObservableObject { } } -@available(iOS 14, *) /// A common ViewModel implementation for handling of `State`, `StateAction`s and `ViewAction`s /// /// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) /// a specific portion of state that can be safely bound to. /// If we decide to add more features to our state management (like doing state processing off the main thread) /// we can do it in this centralised place. +@available(iOS 14, *) class StateStoreViewModel { typealias Context = ViewModelContext @@ -105,7 +105,8 @@ class StateStoreViewModel { self.context = Context(initialViewState: initialViewState) self.state = CurrentValueSubject(initialViewState) // Connect the state to context viewState, that view uses for observing (but not modifying directly) the state. - self.state.weakAssign(to: \.context.viewState, on: self) + self.state + .weakAssign(to: \.context.viewState, on: self) .store(in: &cancellables) // Receive events from the view and pass on to the `ViewModel` for processing. self.context.viewActions.sink { [weak self] action in @@ -120,6 +121,16 @@ class StateStoreViewModel { func dispatch(action: StateAction) { Self.reducer(state: &state.value, 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 + func dispatch(actionPublisher: AnyPublisher) { + actionPublisher.sink { [weak self] action in + guard let self = self else { return } + Self.reducer(state: &self.state.value, action: action) + } + .store(in: &cancellables) + } /// Override to handle mutations to the `State` /// diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift index 0ce280d76..0684ace87 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift @@ -37,6 +37,6 @@ class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { } func simulateUpdate(presence: TemplateUserProfilePresence) { - self.presenceSubject.send(presence) + self.presenceSubject.value = presence } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift index f14b1a2e6..dd9dd9fba 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -26,29 +26,32 @@ class TemplateUserProfileViewModelTests: XCTestCase { static let displayName = "Alice" } var service: MockTemplateUserProfileService! - var viewModel: TemplateUserProfileViewModel! + var viewModel: TemplateUserProfileViewModelProtocol! + var context: TemplateUserProfileViewModelType.Context! var cancellables = Set() override func setUpWithError() throws { service = MockTemplateUserProfileService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) - viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) + viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: service) + context = viewModel.context } func testInitialState() { - XCTAssertEqual(viewModel.viewState.displayName, Constants.displayName) - XCTAssertEqual(viewModel.viewState.presence, Constants.presenceInitialValue) + XCTAssertEqual(context.viewState.displayName, Constants.displayName) + XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) } func testFirstPresenceReceived() throws { - let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(1).first() + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first() XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) } func testPresenceUpdatesReceived() throws { - let presencePublisher = viewModel.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let awaitDeferred = xcAwaitDeferred(presencePublisher) let newPresenceValue1: TemplateUserProfilePresence = .online let newPresenceValue2: TemplateUserProfilePresence = .idle service.simulateUpdate(presence: newPresenceValue1) service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 82834b977..c877a8cee 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -57,9 +57,10 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs } private func setupPresenceObserving() { - templateUserProfileService.presenceSubject + let presenceUpdatePublisher = templateUserProfileService.presenceSubject .map(TemplateUserProfileStateAction.updatePresence) - .sinkDispatchTo(self) + .eraseToAnyPublisher() + dispatch(actionPublisher: presenceUpdatePublisher) } // MARK: - Public From e22848dcda38cd243edce165e6ee689c43d1639a Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 20 Sep 2021 17:05:54 +0100 Subject: [PATCH 29/31] Add counter example to show viewActions modifying the state. --- .../Modules/Common/Extensions/Publisher.swift | 2 +- .../Common/Test/UI/MockScreenTest.swift | 5 +- .../ViewModel/StateStoreViewModel.swift | 71 +++++++++---------- .../TemplateUserProfileCoordinator.swift | 2 + .../TemplateUserProfileStateAction.swift | 2 + .../Model/TemplateUserProfileViewAction.swift | 2 + .../Model/TemplateUserProfileViewState.swift | 1 + .../TemplateUserProfileServiceProtocol.swift | 2 + .../Test/UI/TemplateUserProfileUITests.swift | 4 +- .../View/TemplateUserProfile.swift | 10 ++- .../TemplateUserProfileViewModel.swift | 11 ++- 11 files changed, 65 insertions(+), 47 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift b/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift index 40703befa..e19d516ac 100644 --- a/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift +++ b/RiotSwiftUI/Modules/Common/Extensions/Publisher.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14.0, *) extension Publisher where Failure == Never { - /// Sams as `assign(to:on:)` but maintains a weak reference to object + /// Same as `assign(to:on:)` but maintains a weak reference to object /// /// Useful in cases where you want to pass self and not cause a retain cycle. func weakAssign( diff --git a/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift b/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift index 1146d4715..672f97290 100644 --- a/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift +++ b/RiotSwiftUI/Modules/Common/Test/UI/MockScreenTest.swift @@ -17,9 +17,10 @@ import XCTest import RiotSwiftUI -/// XCTestCase subclass to easy testing of `MockScreenState`'s. +/// XCTestCase subclass to ease testing of `MockScreenState`. /// Creates a test case for each screen state, launches the app, -/// goes to the correct screen and +/// goes to the correct screen and provides the state and key for each +/// invocation of the test. @available(iOS 14.0, *) class MockScreenTest: XCTestCase { diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift index 2cc2b43d5..7ba92ef30 100644 --- a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -1,4 +1,4 @@ -// +// // Copyright 2021 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -34,37 +34,35 @@ import Combine /// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks /// can't be made into the `ViewModel`. @available(iOS 14, *) +@dynamicMemberLookup class ViewModelContext: ObservableObject { // MARK: - Properties - + // MARK: Private - + private var cancellables = Set() fileprivate let viewActions: PassthroughSubject - + // MARK: Public - - /// Set-able/Bindable `Published` property for the bindable portion of the `ViewState` - @Published var bindings: ViewState.BindStateType + /// Get-able/Observable `Published` property for the `ViewState` @Published fileprivate(set) var viewState: ViewState - // MARK: Setup - - init(initialViewState: ViewState) { - self.bindings = initialViewState.bindings - self.viewActions = PassthroughSubject() - self.viewState = initialViewState - if !(initialViewState.bindings is Void) { - // If we have bindable state defined, forward its updates on to the `ViewState` - self.$bindings - .weakAssign(to: \.viewState.bindings, on: self) - .store(in: &cancellables) - } + /// Set-able/Bindable access to the bindable state. + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { viewState.bindings[keyPath: keyPath] } + set { viewState.bindings[keyPath: keyPath] = newValue } } + // MARK: Setup + + init(initialViewState: ViewState) { + self.viewActions = PassthroughSubject() + self.viewState = initialViewState + } + // MARK: Public - + /// Send a `ViewAction` to the `ViewModel` for processing. /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. func send(viewAction: ViewAction) { @@ -80,14 +78,10 @@ class ViewModelContext: ObservableObject { /// we can do it in this centralised place. @available(iOS 14, *) class StateStoreViewModel { - + typealias Context = ViewModelContext - + // MARK: - Properties - - // MARK: Private - - private let state: CurrentValueSubject // MARK: Public @@ -95,39 +89,38 @@ class StateStoreViewModel { /// /// Left as public for `ViewModel` implementations convenience. var cancellables = Set() - + /// Constrained interface for passing to Views. var context: Context - // MARK: Setup + /// State can be read within the 'ViewModel' but not modified outside of the reducer. + var state: State { + context.viewState + } + // MARK: Setup + init(initialViewState: State) { self.context = Context(initialViewState: initialViewState) - self.state = CurrentValueSubject(initialViewState) - // Connect the state to context viewState, that view uses for observing (but not modifying directly) the state. - self.state - .weakAssign(to: \.context.viewState, on: self) - .store(in: &cancellables) - // Receive events from the view and pass on to the `ViewModel` for processing. self.context.viewActions.sink { [weak self] action in guard let self = self else { return } self.process(viewAction: action) } .store(in: &cancellables) } - + /// Send state actions to modify the state within the reducer. /// - Parameter action: The state action to send to the reducer. func dispatch(action: StateAction) { - Self.reducer(state: &state.value, action: action) + 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 func dispatch(actionPublisher: AnyPublisher) { actionPublisher.sink { [weak self] action in guard let self = self else { return } - Self.reducer(state: &self.state.value, action: action) + Self.reducer(state: &self.context.viewState, action: action) } .store(in: &cancellables) } @@ -141,7 +134,7 @@ class StateStoreViewModel { class func reducer(state: inout State, action: StateAction) { //Default implementation, -no-op } - + /// Override to handles incoming `ViewAction`s from the `ViewModel`. /// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation. func process(viewAction: ViewAction) { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 6d4a8780c..6707bd839 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -48,7 +48,9 @@ final class TemplateUserProfileCoordinator: Coordinator { // MARK: - Public func start() { + MXLog.debug("[TemplateUserProfileCoordinator] did start.") templateUserProfileViewModel.completion = { [weak self] result in + MXLog.debug("[TemplateUserProfileCoordinator] TemplateUserProfileViewModel did complete with result: \(result).") guard let self = self else { return } switch result { case .cancel, .done: diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift index e36cf35ce..43f5ced03 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift @@ -17,5 +17,7 @@ import Foundation enum TemplateUserProfileStateAction { + case incrementCount + case decrementCount case updatePresence(TemplateUserProfilePresence) } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift index 372e24d30..69d45742f 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift @@ -17,6 +17,8 @@ import Foundation enum TemplateUserProfileViewAction { + case incrementCount + case decrementCount case cancel case done } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift index e7e7b9317..7f78fc8d5 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift @@ -20,4 +20,5 @@ struct TemplateUserProfileViewState: BindableState { let avatar: AvatarInputProtocol? let displayName: String? var presence: TemplateUserProfilePresence + var count: Int } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift index 452a6b037..c8f003574 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift @@ -25,6 +25,8 @@ protocol TemplateUserProfileServiceProtocol: Avatarable { var presenceSubject: CurrentValueSubject { get } } +// MARK: Avatarable + @available(iOS 14.0, *) extension TemplateUserProfileServiceProtocol { var mxContentUri: String? { diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift index 3507fcdf9..1b1529e67 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift @@ -41,13 +41,13 @@ class TemplateUserProfileUITests: MockScreenTest { func verifyTemplateUserProfilePresence(presence: TemplateUserProfilePresence) { let presenceText = app.staticTexts["presenceText"] XCTAssert(presenceText.exists) - XCTAssert(presenceText.label == presence.title) + XCTAssertEqual(presenceText.label, presence.title) } func verifyTemplateUserProfileLongName(name: String) { let displayNameText = app.staticTexts["displayNameText"] XCTAssert(displayNameText.exists) - XCTAssert(displayNameText.label == name) + XCTAssertEqual(displayNameText.label, name) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index 30902c482..aa1d19dd2 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -38,10 +38,16 @@ struct TemplateUserProfile: View { presence: viewModel.viewState.presence ) Divider() - VStack{ - Text("More great user content!") + HStack{ + Text("Counter: \(viewModel.viewState.count)") .font(theme.fonts.title2) .foregroundColor(theme.colors.secondaryContent) + Button("-") { + viewModel.send(viewAction: .decrementCount) + } + Button("+") { + viewModel.send(viewAction: .incrementCount) + } } .frame(maxHeight: .infinity) } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index c877a8cee..6d19e403f 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -52,7 +52,8 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs return TemplateUserProfileViewState( avatar: templateUserProfileService.avatarData, displayName: templateUserProfileService.displayName, - presence: templateUserProfileService.presenceSubject.value + presence: templateUserProfileService.presenceSubject.value, + count: 0 ) } @@ -71,6 +72,10 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs cancel() case .done: done() + case .incrementCount: + dispatch(action: .incrementCount) + case .decrementCount: + dispatch(action: .decrementCount) } } @@ -78,6 +83,10 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs switch action { case .updatePresence(let presence): state.presence = presence + case .incrementCount: + state.count += 1 + case .decrementCount: + state.count -= 1 } UILog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") } From 1da66740067915b10a51d7dde694082ccf5f69c8 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 21 Sep 2021 11:22:05 +0100 Subject: [PATCH 30/31] Add viewAction to state action. --- .../TemplateUserProfileStateAction.swift | 3 +-- .../TemplateUserProfileViewModel.swift | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift index 43f5ced03..f0695826b 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift @@ -17,7 +17,6 @@ import Foundation enum TemplateUserProfileStateAction { - case incrementCount - case decrementCount + case viewAction(TemplateUserProfileViewAction) case updatePresence(TemplateUserProfilePresence) } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index 6d19e403f..a19903347 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -42,7 +42,7 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs return TemplateUserProfileViewModel(templateUserProfileService: templateUserProfileService) } - fileprivate init(templateUserProfileService: TemplateUserProfileServiceProtocol) { + private init(templateUserProfileService: TemplateUserProfileServiceProtocol) { self.templateUserProfileService = templateUserProfileService super.init(initialViewState: Self.defaultState(templateUserProfileService: templateUserProfileService)) setupPresenceObserving() @@ -72,10 +72,8 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs cancel() case .done: done() - case .incrementCount: - dispatch(action: .incrementCount) - case .decrementCount: - dispatch(action: .decrementCount) + case .incrementCount, .decrementCount: + dispatch(action: .viewAction(viewAction)) } } @@ -83,10 +81,15 @@ class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUs switch action { case .updatePresence(let presence): state.presence = presence - case .incrementCount: - state.count += 1 - case .decrementCount: - state.count -= 1 + 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)") } From 82bcd8ce798ea16e26175254b02568fbfd54cf6e Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 21 Sep 2021 15:52:53 +0100 Subject: [PATCH 31/31] Add Pause/Resume support between rooms. --- Riot/Modules/Room/RoomViewController.m | 2 +- .../VoiceMessageMediaServiceProvider.swift | 26 ++++++++++++------- changelog.d/47773.change | 1 + 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 changelog.d/47773.change diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 301c568ba..ad1b42e7e 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -631,7 +631,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; self.updateRoomReadMarker = NO; isAppeared = NO; - [VoiceMessageMediaServiceProvider.sharedProvider stopAllServices]; + [VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices]; } - (void)viewDidAppear:(BOOL)animated diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift index 1cca80dd3..3037c67d0 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift @@ -31,7 +31,13 @@ import MediaPlayer private var displayLink: CADisplayLink! - // Retain currently playing audio player so it doesn't stop playing on timeline cell reuse + + + // Retain active audio players(playing or paused) so it doesn't stop playing on timeline cell reuse + // and we can pause/resume players on switching rooms. + private var activeAudioPlayers: Set + + // Keep reference to currently playing player for remote control. private var currentlyPlayingAudioPlayer: VoiceMessageAudioPlayer? @objc public static let sharedProvider = VoiceMessageMediaServiceProvider() @@ -87,7 +93,7 @@ import MediaPlayer private override init() { audioPlayers = NSMapTable(valueOptions: .weakMemory) audioRecorders = NSHashTable(options: .weakMemory) - + activeAudioPlayers = Set() super.init() displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector) @@ -113,16 +119,17 @@ import MediaPlayer return audioRecorder } - @objc func stopAllServices() { - stopAllServicesExcept(nil) + @objc func pauseAllServices() { + pauseAllServicesExcept(nil) } // MARK: - VoiceMessageAudioPlayerDelegate func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { currentlyPlayingAudioPlayer = audioPlayer + activeAudioPlayers.insert(audioPlayer) setUpRemoteCommandCenter() - stopAllServicesExcept(audioPlayer) + pauseAllServicesExcept(audioPlayer) } func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { @@ -130,6 +137,7 @@ import MediaPlayer currentlyPlayingAudioPlayer = nil tearDownRemoteCommandCenter() } + activeAudioPlayers.remove(audioPlayer) } func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { @@ -137,17 +145,18 @@ import MediaPlayer currentlyPlayingAudioPlayer = nil tearDownRemoteCommandCenter() } + activeAudioPlayers.remove(audioPlayer) } // MARK: - VoiceMessageAudioRecorderDelegate func audioRecorderDidStartRecording(_ audioRecorder: VoiceMessageAudioRecorder) { - stopAllServicesExcept(audioRecorder) + pauseAllServicesExcept(audioRecorder) } // MARK: - Private - private func stopAllServicesExcept(_ service: AnyObject?) { + private func pauseAllServicesExcept(_ service: AnyObject?) { for audioRecorder in audioRecorders.allObjects { if audioRecorder === service { continue @@ -165,8 +174,7 @@ import MediaPlayer continue } - audioPlayer.stop() - audioPlayer.unloadContent() + audioPlayer.pause() } } diff --git a/changelog.d/47773.change b/changelog.d/47773.change new file mode 100644 index 000000000..826cb00bd --- /dev/null +++ b/changelog.d/47773.change @@ -0,0 +1 @@ +Voice Messages: Pause playback when changing rooms while retaining the playback position when re-entering.