Merge branch 'develop' into stefan/4881-configurable-app-name-localization-strings

# Conflicts:
#	Riot/Generated/Strings.swift
This commit is contained in:
Stefan Ceriu
2021-09-22 17:17:05 +03:00
89 changed files with 1953 additions and 240 deletions

View File

@@ -63,6 +63,7 @@
"switch" = "Switch";
"more" = "More";
"less" = "Less";
"done" = "Done";
// Call Bar
"callbar_only_single_active" = "Tap to return to the call (%@)";

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -631,7 +631,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
self.updateRoomReadMarker = NO;
isAppeared = NO;
[VoiceMessageMediaServiceProvider.sharedProvider stopAllServices];
[VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices];
}
- (void)viewDidAppear:(BOOL)animated

View File

@@ -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<VoiceMessageAudioPlayer>
// 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<NSString, VoiceMessageAudioPlayer>(valueOptions: .weakMemory)
audioRecorders = NSHashTable<VoiceMessageAudioRecorder>(options: .weakMemory)
activeAudioPlayers = Set<VoiceMessageAudioPlayer>()
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()
}
}

View File

@@ -55,6 +55,7 @@ targets:
# Riot will provide it's own LocaleProviderType so exclude.
excludes:
- "Common/Locale/LocaleProvider.swift"
- "**/Test/**"
- path: ../Tools
excludes:
- "Logs"

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UILaunchScreen</key>
<dict/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>

View File

@@ -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 {

View File

@@ -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

View File

@@ -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?

View File

@@ -16,4 +16,4 @@
import Foundation
protocol AvatarType { }
protocol AvatarProtocol { }

View File

@@ -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
)
}
}

View File

@@ -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<UIImage, Error> {

View File

@@ -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<UIImage, Error> {
Future { promise in
promise(.success(Asset.Images.appSymbol.image))

View File

@@ -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,

View File

@@ -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<UIImage, Error>
}

View File

@@ -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<AnyCancellable>()
/// 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

View File

@@ -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 {

View File

@@ -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>() -> 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<T>(dependency: T) {
let key = String(describing: T.self)
dependencyStore[key] = dependency

View File

@@ -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<T>(_ dependency: T) -> some View {
transformEnvironment(\.dependencies) { container in
container.register(dependency: dependency)

View File

@@ -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<Value> {
static subscript<T: Injectable>(

View File

@@ -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
}

View File

@@ -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!
}

View File

@@ -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<T: AnyObject>(
to keyPath: ReferenceWritableKeyPath<T, Output>,
on object: T

View File

@@ -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?)
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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]
}

View File

@@ -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..<screenStates.count).map(String.init)
}
/// Render each of the screen states in a group applying
/// any optional environment variables.
/// - Parameters:
/// - themeId: id of theme to render the screens with
/// - locale: Locale to render the screens with
/// - sizeCategory: type sizeCategory to render the screens with
/// - Returns: The group of screens
static func screenGroup(
themeId: ThemeIdentifier = .light,
locale: Locale = Locale.current,
sizeCategory: ContentSizeCategory = ContentSizeCategory.medium
) -> some View {
Group {
ForEach(0..<screensViews.count) { index in
screensViews[index]
}
}
.theme(themeId)
.environment(\.locale, locale)
.environment(\.sizeCategory, sizeCategory)
}
/// A title to represent the screen and it's screen state
var screenTitle: String {
"\(String(describing: screenType.self)): \(stateTitle)"
}
/// A title to represent this screen state
var stateTitle: String {
String(describing: self)
}
}
@available(iOS 14.0, *)
extension MockScreenState where Self: CaseIterable {
static var screenStates: [MockScreenState] {
return Array(self.allCases)
}
}

View File

@@ -0,0 +1,49 @@
//
// 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 ScreenList: View {
private var allStates: [MockScreenState]
init(screens: [MockScreenState.Type]) {
self.allStates = screens.flatMap{ $0.screenStates }
}
var body: some View {
NavigationView {
List {
ForEach(0..<allStates.count) { i in
let state = allStates[i]
NavigationLink(destination: state.screenView) {
Text(state.screenTitle)
.accessibilityIdentifier(String(i))
}
}
}
}
.navigationTitle("Screen States")
}
}
@available(iOS 14.0, *)
struct ScreenList_Previews: PreviewProvider {
static var previews: some View {
ScreenList(screens: [MockTemplateUserProfileScreenState.self])
}
}

View File

@@ -0,0 +1,75 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
/// XCTestCase subclass to ease testing of `MockScreenState`.
/// Creates a test case for each screen state, launches the app,
/// goes to the correct screen and provides the state and key for each
/// invocation of the test.
@available(iOS 14.0, *)
class MockScreenTest: XCTestCase {
enum Constants {
static let defaultTimeout: TimeInterval = 3
}
class var screenType: MockScreenState.Type? {
return nil
}
class func createTest() -> 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()
}
}

View File

@@ -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<T: Publisher>(
_ 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<T: Publisher>(
_ publisher: T,
timeout: TimeInterval = 10
) -> (() throws -> (T.Output)) {
var result: Result<T.Output, Error>?
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()
}
}
}

View File

@@ -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:

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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.")
}
}
}

View File

@@ -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<ViewState:BindableState, ViewAction>: ObservableObject {
// MARK: - Properties
// MARK: Private
private var cancellables = Set<AnyCancellable>()
fileprivate let viewActions: PassthroughSubject<ViewAction, Never>
// 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<T>(dynamicMember keyPath: WritableKeyPath<ViewState.BindStateType, T>) -> 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<State: BindableState, StateAction, ViewAction> {
typealias Context = ViewModelContext<State, ViewAction>
// MARK: - Properties
// MARK: Public
/// For storing subscription references.
///
/// Left as public for `ViewModel` implementations convenience.
var cancellables = Set<AnyCancellable>()
/// 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<StateAction, Never>) {
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
}
}

View File

@@ -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)

View File

@@ -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?
}

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -55,7 +55,7 @@ class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModelType {
convenience init(
roomNotificationService: RoomNotificationSettingsServiceType,
avatarData: AvatarType?,
avatarData: AvatarProtocol?,
displayName: String?,
roomEncrypted: Bool
) {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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?)
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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<BottomSection: View>: View {

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<TemplateUserProfilePresence, Never>
// 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
}
}
}

View File

@@ -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))
}
}

View File

@@ -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<TemplateUserProfilePresence, Never>
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<TemplateUserProfilePresence, Never>(presence)
}
func simulateUpdate(presence: TemplateUserProfilePresence) {
self.presenceSubject.value = presence
}
}

View File

@@ -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<TemplateUserProfilePresence, Never> { get }
}
// MARK: Avatarable
@available(iOS 14.0, *)
extension TemplateUserProfileServiceProtocol {
var mxContentUri: String? {
avatarUrl
}
var matrixItemId: String {
userId
}
}

View File

@@ -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)
}
}

View File

@@ -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<AnyCancellable>()
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])
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<TemplateUserProfileViewState,
TemplateUserProfileStateAction,
TemplateUserProfileViewAction>
@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)
}
}

View File

@@ -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 }
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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/**"

View File

@@ -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

View File

@@ -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

1
changelog.d/47773.change Normal file
View File

@@ -0,0 +1 @@
Voice Messages: Pause playback when changing rooms while retaining the playback position when re-entering.

View File

@@ -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