diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index b05a5ebd7..3f32ac1dc 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 98c8341e9..83c91f122 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1139,6 +1139,10 @@ public class VectorL10n: NSObject { public static var doNotAskAgain: String { return VectorL10n.tr("Vector", "do_not_ask_again") } + /// Done + public static var done: String { + return VectorL10n.tr("Vector", "done") + } /// %@ 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. public static func e2eEnablingOnAppUpdate(_ p1: String) -> String { return VectorL10n.tr("Vector", "e2e_enabling_on_app_update", p1) 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 c9268a075..34964f23c 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/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/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 249b93f9a..7c164a322 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/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/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/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/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 new file mode 100644 index 000000000..4e5062b5f --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/Model/Avatarable.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 + +/// 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 { + AvatarInput( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: displayName + ) + } +} 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/AvatarService.swift similarity index 82% rename from Riot/Modules/Common/Avatar/AvatarService.swift rename to RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift index 1e73834e7..34b6db55a 100644 --- a/Riot/Modules/Common/Avatar/AvatarService.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/Service/MatrixSDK/AvatarService.swift @@ -24,7 +24,7 @@ enum AvatarServiceError: Error { case loadingImageFailed(Error?) } -class AvatarService: AvatarServiceType { +class AvatarService: AvatarServiceProtocol { private enum Constants { static let mimeType = "image/jpeg" @@ -33,18 +33,21 @@ class AvatarService: AvatarServiceType { private let mediaManager: MXMediaManager + static func instantiate(mediaManager: MXMediaManager) -> AvatarServiceProtocol { + return AvatarService(mediaManager: mediaManager) + } + init(mediaManager: MXMediaManager) { 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/Mock/MockAvatarService.swift b/RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift similarity index 88% rename from RiotSwiftUI/Modules/Common/Avatar/Mock/MockAvatarService.swift rename to RiotSwiftUI/Modules/Common/Avatar/Service/Mock/MockAvatarService.swift index 0ee87d429..e8032ee07 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/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 89% rename from RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceType.swift rename to RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift index 6025a002d..bc8283ee6 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceType.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarServiceProtocol.swift @@ -19,10 +19,9 @@ import DesignKit import Combine import UIKit -/** - Provides a simple api to retrieve and cache avatar images - */ -protocol AvatarServiceType { + +/// 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 6ac6613ff..6808f0dd6 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -18,19 +18,23 @@ 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: AvatarServiceType + @Inject var avatarService: AvatarServiceProtocol @Published private(set) var viewState = AvatarViewState.empty 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, @@ -47,17 +51,16 @@ 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) } .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 "" @@ -65,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..e19d516ac 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 { + /// 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( to keyPath: ReferenceWritableKeyPath, on object: T diff --git a/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift new file mode 100644 index 000000000..806f4b1c7 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Logging/LoggerProtocol.swift @@ -0,0 +1,26 @@ +// +// 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 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?) + 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..29bfc8421 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Logging/PrintLogger.swift @@ -0,0 +1,38 @@ +// +// 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..75c3325af --- /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. +/// +/// It 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/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift new file mode 100644 index 000000000..e028c5fb1 --- /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 = [MockTemplateUserProfileScreenState.self] +} + diff --git a/RiotSwiftUI/Modules/Common/Mock/MockScreenState.swift b/RiotSwiftUI/Modules/Common/Mock/MockScreenState.swift new file mode 100644 index 000000000..22dc09383 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Mock/MockScreenState.swift @@ -0,0 +1,79 @@ +// +// 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 states. +@available(iOS 14.0, *) +protocol MockScreenState { + static var screenStates: [MockScreenState] { get } + var screenType: Any.Type { get } + var screenView: AnyView { get } + var stateTitle: String { get } +} + +@available(iOS 14.0, *) +extension MockScreenState { + + /// Get a list of the screens for every screen state. + static var screensViews: [AnyView] { + screenStates.map(\.screenView) + } + + /// A unique key to identify each screen state. + static var screenStateKeys: [String] { + return Array(0.. some View { + Group { + ForEach(0.. MockScreenTest { + return MockScreenTest() + } + + var screenState: MockScreenState? + var screenStateKey: String? + let app = XCUIApplication() + + override class var defaultTestSuite: XCTestSuite { + let testSuite = XCTestSuite(name: NSStringFromClass(self)) + guard let screenType = screenType else { + return testSuite + } + // Create a test case for each screen state + screenType.screenStates.enumerated().forEach { index, screenState in + let key = screenType.screenStateKeys[index] + addTestFor(screenState: screenState, screenStateKey: key, toTestSuite: testSuite) + } + return testSuite + } + + 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 { + // For every test case launch the app and go to the relevant screen + continueAfterFailure = false + app.launch() + goToScreen() + } + + private func goToScreen() { + guard let screenKey = screenStateKey else { fatalError("no screen") } + let link = app.buttons[screenKey] + link.tap() + } +} diff --git a/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift new file mode 100644 index 000000000..7c0f2ec72 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Test/XCTestPublisherExtensions.swift @@ -0,0 +1,84 @@ +// +// 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 + +@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 + ) 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") + + 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) + } + ) + 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/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 aee127588..c57034fd7 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/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 new file mode 100644 index 000000000..7ba92ef30 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -0,0 +1,143 @@ +// +// 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 + + +/// 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, *) +@dynamicMemberLookup +class ViewModelContext: ObservableObject { + // MARK: - Properties + + // MARK: Private + + private var cancellables = Set() + fileprivate let viewActions: PassthroughSubject + + // MARK: Public + + /// Get-able/Observable `Published` property for the `ViewState` + @Published fileprivate(set) var viewState: ViewState + + /// 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) { + viewActions.send(viewAction) + } +} + +/// 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 + + // MARK: - Properties + + // 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 + + /// 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.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: &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.context.viewState, action: action) + } + .store(in: &cancellables) + } + + /// 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/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Room/NotificationSettings/Coordinator/RoomNotificationSettingsCoordinator.swift index 786e732b1..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 = AvatarService(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/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/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift new file mode 100644 index 000000000..6707bd839 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -0,0 +1,66 @@ +/* + 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 { + + // MARK: - Properties + + // MARK: Private + + private let parameters: TemplateUserProfileCoordinatorParameters + private let templateUserProfileHostingController: UIViewController + private var templateUserProfileViewModel: TemplateUserProfileViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: (() -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: TemplateUserProfileCoordinatorParameters) { + self.parameters = parameters + 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) + } + + // 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: + self.completion?() + break + } + } + } + + func toPresentable() -> UIViewController { + return self.templateUserProfileHostingController + } +} 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/TemplateUserProfilePresence.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfilePresence.swift new file mode 100644 index 000000000..c2d61aaea --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfilePresence.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 TemplateUserProfilePresence { + case online + case idle + case offline +} + +extension TemplateUserProfilePresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension TemplateUserProfilePresence: CaseIterable { } + +extension TemplateUserProfilePresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.swift new file mode 100644 index 000000000..f0695826b --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileStateAction.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 TemplateUserProfileStateAction { + case viewAction(TemplateUserProfileViewAction) + case updatePresence(TemplateUserProfilePresence) +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.swift new file mode 100644 index 000000000..69d45742f --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewAction.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 + +enum TemplateUserProfileViewAction { + case incrementCount + case decrementCount + case cancel + case done +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewModelResult.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewModelResult.swift new file mode 100644 index 000000000..2c2965f5b --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/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/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift new file mode 100644 index 000000000..7f78fc8d5 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.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 + +struct TemplateUserProfileViewState: BindableState { + let avatar: AvatarInputProtocol? + let displayName: String? + var presence: TemplateUserProfilePresence + var count: Int +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift new file mode 100644 index 000000000..d42fd39a1 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/MatrixSDK/TemplateUserProfileService.swift @@ -0,0 +1,88 @@ +// +// 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 TemplateUserProfileService: TemplateUserProfileServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private var listenerReference: Any? + + // MARK: Public + + var userId: String { + session.myUser.userId + } + + var displayName: String? { + session.myUser.displayname + } + + var avatarUrl: String? { + session.myUser.avatarUrl + } + + private(set) var presenceSubject: CurrentValueSubject + + // MARK: - Setup + + init(session: MXSession) { + self.session = session + self.presenceSubject = CurrentValueSubject(TemplateUserProfilePresence(mxPresence: session.myUser.presence)) + 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.presenceSubject.send(TemplateUserProfilePresence(mxPresence: self.session.myUser.presence)) + } + if reference == nil { + UILog.error("[TemplateUserProfileService] Did not recieve a lisenter reference.") + } + return reference + } +} + +fileprivate extension TemplateUserProfilePresence { + + 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/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift new file mode 100644 index 000000000..549716975 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.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 MockTemplateUserProfileScreenState: 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: [MockTemplateUserProfileScreenState] { + // Each of the presence statuses + TemplateUserProfilePresence.allCases.map(MockTemplateUserProfileScreenState.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.makeTemplateUserProfileViewModel(templateUserProfileService: service) + + // can simulate service and viewModel actions here if needs be. + + return AnyView(TemplateUserProfile(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.swift new file mode 100644 index 000000000..0684ace87 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileService.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 +import Combine + +@available(iOS 14.0, *) +class MockTemplateUserProfileService: TemplateUserProfileServiceProtocol { + var presenceSubject: CurrentValueSubject + + let userId: String + let displayName: String? + let avatarUrl: String? + init( + userId: String = "@alice:matrix.org", + displayName: String? = "Alice", + avatarUrl: String? = "mxc://matrix.org/VyNYAgahaiAzUoOeZETtQ", + presence: TemplateUserProfilePresence = .offline + ) { + self.userId = userId + self.displayName = displayName + self.avatarUrl = avatarUrl + self.presenceSubject = CurrentValueSubject(presence) + } + + func simulateUpdate(presence: TemplateUserProfilePresence) { + self.presenceSubject.value = presence + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift new file mode 100644 index 000000000..c8f003574 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/TemplateUserProfileServiceProtocol.swift @@ -0,0 +1,38 @@ +// +// 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, *) +protocol TemplateUserProfileServiceProtocol: Avatarable { + var userId: String { get } + var displayName: String? { get } + var avatarUrl: String? { get } + var presenceSubject: CurrentValueSubject { get } +} + +// MARK: Avatarable + +@available(iOS 14.0, *) +extension TemplateUserProfileServiceProtocol { + var mxContentUri: String? { + avatarUrl + } + var matrixItemId: String { + userId + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.swift new file mode 100644 index 000000000..1b1529e67 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/UI/TemplateUserProfileUITests.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 RiotSwiftUI + +@available(iOS 14.0, *) +class TemplateUserProfileUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockTemplateUserProfileScreenState.self + } + + override class func createTest() -> MockScreenTest { + return TemplateUserProfileUITests(selector: #selector(verifyTemplateUserProfileScreen)) + } + + func verifyTemplateUserProfileScreen() throws { + guard let screenState = screenState as? MockTemplateUserProfileScreenState else { fatalError("no screen") } + switch screenState { + case .presence(let presence): + verifyTemplateUserProfilePresence(presence: presence) + case .longDisplayName(let name): + verifyTemplateUserProfileLongName(name: name) + } + } + + func verifyTemplateUserProfilePresence(presence: TemplateUserProfilePresence) { + let presenceText = app.staticTexts["presenceText"] + XCTAssert(presenceText.exists) + XCTAssertEqual(presenceText.label, presence.title) + } + + func verifyTemplateUserProfileLongName(name: String) { + let displayNameText = app.staticTexts["displayNameText"] + XCTAssert(displayNameText.exists) + XCTAssertEqual(displayNameText.label, name) + } + +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift new file mode 100644 index 000000000..dd9dd9fba --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Test/Unit/TemplateUserProfileViewModelTests.swift @@ -0,0 +1,57 @@ +// +// 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 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: TemplateUserProfileViewModelProtocol! + var context: TemplateUserProfileViewModelType.Context! + var cancellables = Set() + override func setUpWithError() throws { + service = MockTemplateUserProfileService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) + viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.displayName, Constants.displayName) + XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) + } + + func testFirstPresenceReceived() throws { + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first() + XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) + } + + func testPresenceUpdatesReceived() throws { + 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 awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift new file mode 100644 index 000000000..aa1d19dd2 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -0,0 +1,79 @@ +// +// 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 { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: TemplateUserProfileViewModel.Context + + var body: some View { + EmptyView() + VStack { + TemplateUserProfileHeader( + avatar: viewModel.viewState.avatar, + displayName: viewModel.viewState.displayName, + presence: viewModel.viewState.presence + ) + Divider() + 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) + } + .background(theme.colors.background) + .frame(maxHeight: .infinity) + .navigationTitle(viewModel.viewState.displayName ?? "") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(VectorL10n.done) { + viewModel.send(viewAction: .done) + } + } + ToolbarItem(placement: .cancellationAction) { + Button(VectorL10n.cancel) { + viewModel.send(viewAction: .cancel) + } + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct TemplateUserProfile_Previews: PreviewProvider { + static var previews: some View { + MockTemplateUserProfileScreenState.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.swift new file mode 100644 index 000000000..1c169f506 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfileHeader.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 + +@available(iOS 14.0, *) +struct TemplateUserProfileHeader: View { + + // MARK: - Properties + + // MARK: Private + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + let avatar: AvatarInputProtocol? + let displayName: String? + let presence: TemplateUserProfilePresence + + var body: some View { + VStack { + if let avatar = avatar { + AvatarImage(avatarData: avatar, size: .xxLarge) + .padding(.vertical) + } + VStack(spacing: 8){ + Text(displayName ?? "") + .font(theme.fonts.title3) + .accessibility(identifier: "displayNameText") + .padding(.horizontal) + .lineLimit(1) + TemplateUserProfilePresenceView(presence: presence) + } + } + } +} + +// MARK: - Previews + +@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/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift new file mode 100644 index 000000000..3d724f540 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfilePresenceView.swift @@ -0,0 +1,66 @@ +// +// 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 TemplateUserProfilePresenceView: View { + + // MARK: - Properties + + // MARK: Public + let presence: TemplateUserProfilePresence + + var body: some View { + HStack { + Image(systemName: "circle.fill") + .resizable() + .frame(width: 8, height: 8) + Text(presence.title) + .font(.subheadline) + .accessibilityIdentifier("presenceText") + } + .foregroundColor(foregroundColor) + .padding(0) + } + + // MARK: View Components + + private var foregroundColor: Color { + switch presence { + 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 { + VStack(alignment:.leading){ + Text("Presence") + ForEach(TemplateUserProfilePresence.allCases) { presence in + TemplateUserProfilePresenceView(presence: presence) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift new file mode 100644 index 000000000..a19903347 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -0,0 +1,104 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + + + +@available(iOS 14, *) +typealias TemplateUserProfileViewModelType = StateStoreViewModel +@available(iOS 14, *) +class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUserProfileViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let templateUserProfileService: TemplateUserProfileServiceProtocol + + // MARK: Public + + var completion: ((TemplateUserProfileViewModelResult) -> Void)? + + // MARK: - Setup + + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol { + return TemplateUserProfileViewModel(templateUserProfileService: templateUserProfileService) + } + + private init(templateUserProfileService: TemplateUserProfileServiceProtocol) { + self.templateUserProfileService = templateUserProfileService + super.init(initialViewState: Self.defaultState(templateUserProfileService: templateUserProfileService)) + setupPresenceObserving() + } + + private static func defaultState(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { + return TemplateUserProfileViewState( + avatar: templateUserProfileService.avatarData, + displayName: templateUserProfileService.displayName, + presence: templateUserProfileService.presenceSubject.value, + count: 0 + ) + } + + private func setupPresenceObserving() { + let presenceUpdatePublisher = templateUserProfileService.presenceSubject + .map(TemplateUserProfileStateAction.updatePresence) + .eraseToAnyPublisher() + dispatch(actionPublisher: presenceUpdatePublisher) + } + + // MARK: - Public + + override func process(viewAction: TemplateUserProfileViewAction) { + switch viewAction { + case .cancel: + cancel() + case .done: + done() + case .incrementCount, .decrementCount: + dispatch(action: .viewAction(viewAction)) + } + } + + override class func reducer(state: inout TemplateUserProfileViewState, action: TemplateUserProfileStateAction) { + switch action { + case .updatePresence(let presence): + state.presence = presence + case .viewAction(let viewAction): + switch viewAction { + case .incrementCount: + state.count += 1 + case .decrementCount: + state.count -= 1 + case .cancel, .done: + break + } + } + UILog.debug("[TemplateUserProfileViewModel] reducer with action \(action) produced state: \(state)") + } + + private func done() { + completion?(.done) + } + + private func cancel() { + completion?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift new file mode 100644 index 000000000..271ec3c38 --- /dev/null +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift @@ -0,0 +1,26 @@ +// +// 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 } + @available(iOS 14, *) + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol + @available(iOS 14, *) + var context: TemplateUserProfileViewModelType.Context { get } +} diff --git a/RiotSwiftUI/RiotSwiftUIApp.swift b/RiotSwiftUI/RiotSwiftUIApp.swift index 417ae1872..63dbece0d 100644 --- a/RiotSwiftUI/RiotSwiftUIApp.swift +++ b/RiotSwiftUI/RiotSwiftUIApp.swift @@ -15,16 +15,16 @@ // import SwiftUI -/** - Just needed so the application target has an entry point for the moment. - Could use to render the different screens. - */ @available(iOS 14.0, *) @main -struct testApp: App { +/// RiotSwiftUI screens rendered for UI Tests. +struct RiotSwiftUIApp: App { + init() { + UILog.configure(logger: PrintLogger.self) + } var body: some Scene { WindowGroup { - Text("app") + ScreenList(screens: MockAppScreens.appScreens) } } } 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/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml new file mode 100644 index 000000000..4466226ad --- /dev/null +++ b/RiotSwiftUI/targetUITests.yml @@ -0,0 +1,57 @@ +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 + + settings: + base: + TEST_TARGET_NAME: RiotSwiftUI + PRODUCT_BUNDLE_IDENTIFIER: org.matrix.RiotSwiftUITests$(rfc1034identifier) + sources: + # Source included/excluded here here are similar to RiotSwiftUI as we + # need access to ScreenStates + - path: ../RiotSwiftUI/Modules + 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 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/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 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 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. 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