diff --git a/Riot/Modules/Common/Avatar/AvatarInputType.swift b/Riot/Modules/Common/Avatar/AvatarInputType.swift index f0eff17fb..26383a3e2 100644 --- a/Riot/Modules/Common/Avatar/AvatarInputType.swift +++ b/Riot/Modules/Common/Avatar/AvatarInputType.swift @@ -18,13 +18,13 @@ import Foundation protocol AvatarInputType { var mxContentUri: String? { get } - var itemId: String { get } + var matrixItemId: String { get } var displayName: String? { get } } struct AvatarInput: AvatarInputType { let mxContentUri: String? - let itemId: String + var matrixItemId: String let displayName: String? } diff --git a/Riot/Modules/Common/Avatar/Mock/MockAvatarInput.swift b/Riot/Modules/Common/Avatar/Mock/MockAvatarInput.swift new file mode 100644 index 000000000..4e62c0b50 --- /dev/null +++ b/Riot/Modules/Common/Avatar/Mock/MockAvatarInput.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 + +class MockAvatarInput { + static let example = AvatarInput(mxContentUri: "faveUri", matrixItemId: "id123", displayName: "Alice") +} diff --git a/Riot/Modules/Common/Avatar/MockAvatarService.swift b/Riot/Modules/Common/Avatar/Mock/MockAvatarService.swift similarity index 69% rename from Riot/Modules/Common/Avatar/MockAvatarService.swift rename to Riot/Modules/Common/Avatar/Mock/MockAvatarService.swift index a29084a0a..b1c9627c8 100644 --- a/Riot/Modules/Common/Avatar/MockAvatarService.swift +++ b/Riot/Modules/Common/Avatar/Mock/MockAvatarService.swift @@ -16,15 +16,14 @@ import Foundation import Combine +import DesignKit @available(iOS 14.0, *) class MockAvatarService: AvatarServiceType { - static let example = MockAvatarService() - func avatarImage(inputData: AvatarInputType) -> AnyPublisher { - guard let image = AvatarGenerator.generateAvatar(forText: inputData.displayName ?? "") else { - fatalError() + static let example: AvatarServiceType = MockAvatarService() + func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future { + Future { promise in + promise(.success(UIImage(imageLiteralResourceName: "app_symbol"))) } - return Just(image) - .eraseToAnyPublisher() } } diff --git a/Riot/Modules/Common/Avatar/SwiftUI/AvatarImage.swift b/Riot/Modules/Common/Avatar/SwiftUI/AvatarImage.swift new file mode 100644 index 000000000..fa22a4fc2 --- /dev/null +++ b/Riot/Modules/Common/Avatar/SwiftUI/AvatarImage.swift @@ -0,0 +1,108 @@ +// +// 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 DesignKit + +@available(iOS 14.0, *) +struct AvatarImage: View { + + @Environment(\.theme) var theme: Theme + @Environment(\.dependencies) var dependencies: DependencyContainer + @StateObject var viewModel = AvatarViewModel() + + var mxContentUri: String? + var matrixItemId: String + var displayName: String = "" + var size: AvatarSize + + var body: some View { + Group { + if let image = viewModel.viewState.avatarImage { + Image(uiImage: image) + .resizable() + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .clipShape(Circle()) + } else { + Text(firstCharacterCapitalized(displayName)) + .padding(4) + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .foregroundColor(.white) + .background(stableColor) + .clipShape(Circle()) + .font(.system(size: 200)) + .minimumScaleFactor(0.001) + } + } + .onAppear { + viewModel.dependencies = dependencies + viewModel.loadAvatar( + mxContentUri: mxContentUri, + avatarSize: size + ) + } + } + + private func firstCharacterCapitalized(_ string: String) -> String { + guard let character = string.first else { + return "" + } + return String(character).capitalized + } + + var stableColor: Color { + let sum = matrixItemId.utf8 + .map({ UInt($0) }) + .reduce(0, +) + let index = Int(sum) % theme.avatarColors.count + return Color(theme.avatarColors[index]) + } +} + +@available(iOS 14.0, *) +extension AvatarImage { + init(avatarData: AvatarInputType, size: AvatarSize) { + self.init( + mxContentUri: avatarData.mxContentUri, + matrixItemId: avatarData.matrixItemId, + displayName: avatarData.displayName ?? "", + size: size + ) + } +} + +@available(iOS 14.0, *) +struct AvatarImage_Previews: PreviewProvider { + static let mxContentUri = "fakeUri" + static let name = "Alice" + static var previews: some View { + Group { + HStack { + VStack(alignment: .center, spacing: 20) { + AvatarImage(avatarData: MockAvatarInput.example, size: .xSmall) + AvatarImage(avatarData: MockAvatarInput.example, size: .medium) + AvatarImage(avatarData: MockAvatarInput.example, size: .xLarge) + } + VStack(alignment: .center, spacing: 20) { + AvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xSmall) + AvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .medium) + AvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xLarge) + } + } + .addDependency(MockAvatarService.example) + } + } +} diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/UIKit/AvatarView.swift similarity index 100% rename from Riot/Modules/Common/Avatar/AvatarView.swift rename to Riot/Modules/Common/Avatar/UIKit/AvatarView.swift diff --git a/Riot/Modules/Common/Avatar/AvatarService.swift b/Riot/Modules/Common/Avatar/ViewModel/AvatarService.swift similarity index 54% rename from Riot/Modules/Common/Avatar/AvatarService.swift rename to Riot/Modules/Common/Avatar/ViewModel/AvatarService.swift index 02fabf2d2..fefc8726f 100644 --- a/Riot/Modules/Common/Avatar/AvatarService.swift +++ b/Riot/Modules/Common/Avatar/ViewModel/AvatarService.swift @@ -24,7 +24,7 @@ import DesignKit */ protocol AvatarServiceType { @available(iOS 14.0, *) - func avatarImage(inputData: AvatarInputType) -> AnyPublisher + func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future } enum AvatarServiceError: Error { @@ -37,55 +37,43 @@ class AvatarService: AvatarServiceType { private enum Constants { static let mimeType = "image/jpeg" static let thumbnailMethod = MXThumbnailingMethodCrop - static let avatarDownloadSize = AvatarSize.xxLarge.size } - private let avatarGenerator: AvatarGenerator private let mediaManager: MXMediaManager - init(avatarGenerator: AvatarGenerator, mediaManager: MXMediaManager) { - self.avatarGenerator = avatarGenerator + init(mediaManager: MXMediaManager) { self.mediaManager = mediaManager } /** - Given an avatar input, this function returns a publisher of UIImage. - If possible to retrieve the actualy image it will from network or cache. - While waiting on the network or if one can't be retrieved it will return the generated avatar. + Given an mxContentUri, this function returns a future of UIImage. + If possible to retrieve the actual image it will from network or cache, otherwise it will error. - - Parameter inputData: data required to fetch the avatar or generate the image - - Returns: A publisher of UIImage? that doesn't error(always possible to generate the image) + - Parameter mxContentUri: matrix uri of the avatar to fetch + - Returns: A Future of UIImage that returns an error if it fails to fetch the image */ @available(iOS 14.0, *) - func avatarImage(inputData: AvatarInputType) -> AnyPublisher { - - let generatedAvatar = AvatarGenerator.generateAvatar(forMatrixItem: inputData.itemId, withDisplayName: inputData.displayName) - guard let mxContentUri = inputData.mxContentUri else { - // No content URI just complete with the generated avatar - return Just(generatedAvatar) - .eraseToAnyPublisher() - } + func avatarImage(mxContentUri: String, avatarSize: AvatarSize) -> Future { let cachePath = MXMediaManager.thumbnailCachePath( forMatrixContentURI: mxContentUri, andType: Constants.mimeType, inFolder: nil, - toFitViewSize: Constants.avatarDownloadSize, + toFitViewSize: avatarSize.size, with: Constants.thumbnailMethod) - if let image = MXMediaManager.loadThroughCache(withFilePath: cachePath), - let imageUp = Self.orientImageUp(image: image) { - // Already cached, complete with the avatar - return Just(imageUp) - .eraseToAnyPublisher() - } + return Future { promise in + if let image = MXMediaManager.loadThroughCache(withFilePath: cachePath), + let imageUp = Self.orientImageUp(image: image) { + // Already cached return avatar + promise(.success(imageUp)) + } - let future = Future { promise in self.mediaManager.downloadThumbnail( fromMatrixContentURI: mxContentUri, withType: Constants.mimeType, inFolder: nil, - toFitViewSize: Constants.avatarDownloadSize, + toFitViewSize: avatarSize.size, with: Constants.thumbnailMethod) { path in guard let path = path else { promise(.failure(AvatarServiceError.pathNotfound)) @@ -102,16 +90,6 @@ class AvatarService: AvatarServiceType { promise(.failure(AvatarServiceError.loadingImageFailed(error))) } } - // First publish the generated avatar and then complete with the retrieved one - // In the case of an error retreiving the avatar also return generated one. - return future - .prepend(generatedAvatar) - .catch { _ -> Just in - MXLog.error("[AvatarService] Failed to retrieve avatar.") - // TODO: Report non-fatal error when we have Sentry or similar. - return Just(generatedAvatar) - } - .eraseToAnyPublisher() } private static func orientImageUp(image: UIImage) -> UIImage? { diff --git a/Riot/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/Riot/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift new file mode 100644 index 000000000..1e9c7a889 --- /dev/null +++ b/Riot/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -0,0 +1,44 @@ +// +// 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 DesignKit + +@available(iOS 14.0, *) +class AvatarViewModel: ObservableObject, Injectable { + + var dependencies: DependencyContainer! + + @Inject var avatarService: AvatarServiceType + + @Published private(set) var viewState = AvatarViewState() + + private var cancellables = Set() + + func loadAvatar(mxContentUri: String?, avatarSize: AvatarSize) { + guard let mxContentUri = mxContentUri else { return } + avatarService.avatarImage(mxContentUri: mxContentUri, avatarSize: avatarSize) + .sink { error in + MXLog.error("[AvatarService] Failed to retrieve avatar.") + // TODO: Report non-fatal error when we have Sentry or similar. + } receiveValue: { image in + self.viewState.avatarImage = image + } + .store(in: &cancellables) + } + +} diff --git a/Riot/Modules/Common/Avatar/ViewModel/AvatarViewState.swift b/Riot/Modules/Common/Avatar/ViewModel/AvatarViewState.swift new file mode 100644 index 000000000..315f085f1 --- /dev/null +++ b/Riot/Modules/Common/Avatar/ViewModel/AvatarViewState.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 AvatarViewState { + var avatarImage: UIImage? +} diff --git a/Riot/Modules/Common/SwiftUI/DependencyContainer.swift b/Riot/Modules/Common/SwiftUI/DependencyContainer.swift new file mode 100644 index 000000000..9314aa171 --- /dev/null +++ b/Riot/Modules/Common/SwiftUI/DependencyContainer.swift @@ -0,0 +1,35 @@ +// +// 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 DependencyContainer { + + private var dependencyList: [String: Any] = [:] + + func resolve() -> T { + let key = String(describing: T.self) + guard let t = dependencyList[key] as? T else { + fatalError("No provider registered for type \(T.self)") + } + return t + } + + mutating func register(dependency: T) { + let key = String(describing: T.self) + dependencyList[key] = dependency + } +} diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorAvatarView.swift b/Riot/Modules/Common/SwiftUI/DependencyContainerKey.swift similarity index 50% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/VectorAvatarView.swift rename to Riot/Modules/Common/SwiftUI/DependencyContainerKey.swift index 14d2f077b..93a9efc24 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorAvatarView.swift +++ b/Riot/Modules/Common/SwiftUI/DependencyContainerKey.swift @@ -1,4 +1,4 @@ -// +// // Copyright 2021 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,31 +14,32 @@ // limitations under the License. // +import Foundation import SwiftUI -import DesignKit -@available(iOS 14.0, *) -struct VectorAvatarView: View { - - var image: UIImage - var size: AvatarSize - - var body: some View { - Image(uiImage: image) - .resizable() - .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue), alignment: .center) - .clipShape(Circle()) - } +private struct DependencyContainerKey: EnvironmentKey { + static let defaultValue = DependencyContainer() } @available(iOS 14.0, *) -struct AvatarView_Previews: PreviewProvider { - static let image = UIImage(imageLiteralResourceName: "app_symbol") - static var previews: some View { - VStack { - VectorAvatarView(image: image, size: .xSmall) - VectorAvatarView(image: image, size: .medium) - VectorAvatarView(image: image, size: .xLarge) +extension EnvironmentValues { + var dependencies: DependencyContainer { + get { self[DependencyContainerKey.self] } + set { self[DependencyContainerKey.self] = newValue } + } +} + +/** + */ +@available(iOS 14.0, *) +extension View { + func setDependencies(_ container: DependencyContainer) -> some View { + environment(\.dependencies, container) + } + + func addDependency(_ dependency: T) -> some View { + transformEnvironment(\.dependencies) { container in + container.register(dependency: dependency) } } } diff --git a/Riot/Modules/Common/SwiftUI/DependencyInjector.swift b/Riot/Modules/Common/SwiftUI/DependencyInjector.swift new file mode 100644 index 000000000..e11ec1ca7 --- /dev/null +++ b/Riot/Modules/Common/SwiftUI/DependencyInjector.swift @@ -0,0 +1,40 @@ +// +// 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 DependencyInjector { + private static var dependencyList: [String:Any] = [:] + + static func resolve() -> T { + guard let t = dependencyList[String(describing: T.self)] as? T else { + fatalError("No provider registered for type \(T.self)") + } + return t + } + + static func register(dependency: T) { + dependencyList[String(describing: T.self)] = dependency + } +} + +@propertyWrapper struct Inject { + var wrappedValue: T + + init() { + self.wrappedValue = DependencyInjector.resolve() + } +} diff --git a/Riot/Modules/Common/SwiftUI/Inject.swift b/Riot/Modules/Common/SwiftUI/Inject.swift new file mode 100644 index 000000000..b80b211cb --- /dev/null +++ b/Riot/Modules/Common/SwiftUI/Inject.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 + +@propertyWrapper struct Inject { + + static subscript( + _enclosingInstance instance: T, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { + let v: Value = instance.dependencies.resolve() + return v + } + set { + fatalError() + } + } + + @available(*, unavailable, + message: "@Published can only be applied to classes" + ) + var wrappedValue: Value { + get { fatalError() } + set { fatalError(" \(newValue)" ) } + } +} diff --git a/Riot/Modules/Common/SwiftUI/Injectable.swift b/Riot/Modules/Common/SwiftUI/Injectable.swift new file mode 100644 index 000000000..90f8397bb --- /dev/null +++ b/Riot/Modules/Common/SwiftUI/Injectable.swift @@ -0,0 +1,21 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol Injectable { + var dependencies: DependencyContainer! { get } +} diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingViewController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift similarity index 89% rename from Riot/Modules/Common/SwiftUI/VectorHostingViewController.swift rename to Riot/Modules/Common/SwiftUI/VectorHostingController.swift index d0f25d0f1..0a5b61379 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingViewController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -22,10 +22,16 @@ import SwiftUI (E.g. vectorContent modifier and themeing to the NavigationController container. */ @available(iOS 14.0, *) -class VectorHostingViewController: UIHostingController { +class VectorHostingController: UIHostingController { + + // MARK: Private + + private var theme: Theme + private var dependencyContainer = DependencyContainer() init(rootView: Content) where Content: View { self.theme = ThemeService.shared().theme + super.init(rootView: AnyView(rootView.vectorContent())) } @@ -33,10 +39,6 @@ class VectorHostingViewController: UIHostingController { fatalError("VectorHostingViewController does not currently support init from nibs") } - // MARK: Private - - private var theme: Theme - // MARK: - Life cycle override func viewDidLoad() { @@ -45,6 +47,10 @@ class VectorHostingViewController: UIHostingController { self.update(theme: self.theme) } + func add(dependency: T) { + dependencyContainer.register(dependency: dependency) + } + private func registerThemeServiceDidChangeThemeNotification() { NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil) } @@ -61,4 +67,3 @@ class VectorHostingViewController: UIHostingController { } } } - diff --git a/Riot/Modules/Room/NotificationSettings/Mocks/MockRoomNotificationSettingsService.swift b/Riot/Modules/Room/NotificationSettings/Mock/MockRoomNotificationSettingsService.swift similarity index 100% rename from Riot/Modules/Room/NotificationSettings/Mocks/MockRoomNotificationSettingsService.swift rename to Riot/Modules/Room/NotificationSettings/Mock/MockRoomNotificationSettingsService.swift diff --git a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsCoordinator.swift b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsCoordinator.swift index ab4fe17e9..52e9af49b 100644 --- a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsCoordinator.swift +++ b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsCoordinator.swift @@ -39,14 +39,12 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin init(room: MXRoom, presentedModally: Bool = true) { let roomNotificationService = RoomNotificationSettingsService(room: room) - let avatarService = AvatarService(avatarGenerator: AvatarGenerator(), mediaManager: room.mxSession.mediaManager) - let avatarData: AvatarInputOption? let showAvatar = presentedModally if #available(iOS 14.0.0, *) { avatarData = showAvatar ? .swiftUI(AvatarInput( mxContentUri: room.summary.avatar, - itemId: room.roomId, + matrixItemId: room.roomId, displayName: room.summary.displayname )) : nil } else { @@ -60,15 +58,16 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin let roomNotificationSettingsViewModel = RoomNotificationSettingsViewModel( roomNotificationService: roomNotificationService, - avatarService: avatarService, avatarData: avatarData, + displayName: room.summary.displayname, roomEncrypted: room.summary.isEncrypted) let viewController: UIViewController if #available(iOS 14.0.0, *) { - let view = RoomNotificationSettingsView(viewModel: roomNotificationSettingsViewModel, presentedModally: presentedModally) - .vectorContent() - viewController = VectorHostingViewController(rootView: view) + let view = RoomNotificationSettings(viewModel: roomNotificationSettingsViewModel, presentedModally: presentedModally) + let host = VectorHostingController(rootView: view) + host.add(dependency: AvatarService(mediaManager: room.mxSession.mediaManager)) + viewController = host } else { viewController = RoomNotificationSettingsViewController.instantiate(with: roomNotificationSettingsViewModel) } diff --git a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewModel.swift b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewModel.swift index 02a77a2d0..e77d5f818 100644 --- a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewModel.swift +++ b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewModel.swift @@ -27,7 +27,6 @@ final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModel // MARK: Private private let roomNotificationService: RoomNotificationSettingsServiceType - private let avatarService: AvatarServiceType private var state: RoomNotificationSettingsViewState { willSet { update(viewState: newValue) @@ -48,15 +47,20 @@ final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModel init( roomNotificationService: RoomNotificationSettingsServiceType, - avatarService: AvatarServiceType, avatarData: AvatarInputOption?, + displayName: String?, roomEncrypted: Bool ) { self.roomNotificationService = roomNotificationService - self.avatarService = avatarService let notificationState = Self.mapNotificationStateOnRead(encrypted: roomEncrypted, state: roomNotificationService.notificationState) - let initialState = RoomNotificationSettingsViewState(roomEncrypted: roomEncrypted, saving: false, notificationState: notificationState, avatar: nil) + let initialState = RoomNotificationSettingsViewState( + roomEncrypted: roomEncrypted, + saving: false, + notificationState: notificationState, + avatarData: avatarData, + displayName: displayName + ) self.state = initialState if #available(iOS 14.0, *) { @@ -67,19 +71,6 @@ final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModel guard let self = self else { return } self.state.notificationState = Self.mapNotificationStateOnRead(encrypted: roomEncrypted, state: state) } - - if #available(iOS 14.0, *), - let avatarData = avatarData, - case let AvatarInputOption.swiftUI(data) = avatarData { - avatarService.avatarImage(inputData: data) - .sink { image in - var newState = self.state - newState.avatar = image - newState.displayName = data.displayName - self.state = newState - } - .store(in: &cancellables) - } } // MARK: - Public diff --git a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewStateType.swift b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewStateType.swift index 20c244dfd..82f3019bb 100644 --- a/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewStateType.swift +++ b/Riot/Modules/Room/NotificationSettings/RoomNotificationSettingsViewStateType.swift @@ -23,9 +23,8 @@ struct RoomNotificationSettingsViewState: RoomNotificationSettingsViewStateType let roomEncrypted: Bool var saving: Bool var notificationState: RoomNotificationState - var avatar: UIImage? + var avatarData: AvatarInputOption? var displayName: String? - var legacyAvatarData: AvatarViewDataProtocol? } extension RoomNotificationSettingsViewState { @@ -43,6 +42,12 @@ protocol RoomNotificationSettingsViewStateType { var roomEncrypted: Bool { get } var notificationOptions: [RoomNotificationState] { get } var notificationState: RoomNotificationState { get } - var avatar: UIImage? { get } - var legacyAvatarData: AvatarViewDataProtocol? { get } + var avatarData: AvatarInputOption? { get } + var displayName: String? { get } +} + +extension RoomNotificationSettingsViewState { + var roomEncryptedString: String { + roomEncrypted ? VectorL10n.roomNotifsSettingsEncryptedRoomNotice : "" + } } diff --git a/Riot/Modules/Room/NotificationSettings/RoomNotificationState.swift b/Riot/Modules/Room/NotificationSettings/RoomNotificationState.swift index 5937eece7..00c51d6fc 100644 --- a/Riot/Modules/Room/NotificationSettings/RoomNotificationState.swift +++ b/Riot/Modules/Room/NotificationSettings/RoomNotificationState.swift @@ -27,3 +27,16 @@ extension RoomNotificationState: CaseIterable { } extension RoomNotificationState: Identifiable { var id: Int { self.rawValue } } + +extension RoomNotificationState { + var title: String { + switch self { + case .all: + return VectorL10n.roomNotifsSettingsAllMessages + case .mentionsAndKeywordsOnly: + return VectorL10n.roomNotifsSettingsMentionsAndKeywords + case .mute: + return VectorL10n.roomNotifsSettingsNone + } + } +} diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormItemButtonStyle.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormItemButtonStyle.swift similarity index 95% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormItemButtonStyle.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/FormItemButtonStyle.swift index 19ff7b9db..efc401b25 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormItemButtonStyle.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormItemButtonStyle.swift @@ -18,7 +18,7 @@ import Foundation import SwiftUI @available(iOS 14.0, *) -struct VectorFormItemButtonStyle: ButtonStyle { +struct FormItemButtonStyle: ButtonStyle { @Environment(\.theme) var theme: Theme func makeBody(configuration: Self.Configuration) -> some View { configuration.label diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItemView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItem.swift similarity index 83% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItemView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItem.swift index d140b38fc..c0f183991 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItemView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormPickerItem.swift @@ -17,19 +17,19 @@ import SwiftUI @available(iOS 14.0, *) -struct FormPickerItemView: View { +struct FormPickerItem: View { - typealias ClickCallback = () -> Void + typealias TapCallback = () -> Void @Environment(\.theme) var theme: Theme var title: String var selected: Bool - var onClick: ClickCallback? + var onTap: TapCallback? var body: some View { Button { - onClick?() + onTap?() } label: { VStack { Spacer() @@ -47,20 +47,20 @@ struct FormPickerItemView: View { } .padding(.leading) } - .buttonStyle(VectorFormItemButtonStyle()) + .buttonStyle(FormItemButtonStyle()) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, idealHeight: 44, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } } @available(iOS 14.0, *) -struct FormPickerCell_Previews: PreviewProvider { +struct FormPickerItem_Previews: PreviewProvider { static let items = ["Item 1", "Item 2", "Item 3"] static var selected: String = items[0] static var previews: some View { - VectorFormView { + VectorForm { ForEach(items, id: \.self) { item in - FormPickerItemView(title: item, selected: selected == item) + FormPickerItem(title: item, selected: selected == item) } } } diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooterView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooter.swift similarity index 71% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooterView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooter.swift index 2ba397034..3e23534e9 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooterView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionFooter.swift @@ -17,7 +17,7 @@ import SwiftUI @available(iOS 14.0, *) -struct FormSectionFooterView: View { +struct FormSectionFooter: View { @Environment(\.theme) var theme: Theme var text: String @@ -35,11 +35,11 @@ struct FormSectionFooterView: View { @available(iOS 14.0, *) struct FormSectionFooter_Previews: PreviewProvider { static var previews: some View { - VectorFormView { - SwiftUI.Section(footer: FormSectionFooterView(text: "Please note that mentions & keyword notifications are not available in encrypted rooms on mobile.")) { - FormPickerItemView(title: "Item 1", selected: false) - FormPickerItemView(title: "Item 2", selected: false) - FormPickerItemView(title: "Item 3", selected: false) + VectorForm { + SwiftUI.Section(footer: FormSectionFooter(text: "Please note that mentions & keyword notifications are not available in encrypted rooms on mobile.")) { + FormPickerItem(title: "Item 1", selected: false) + FormPickerItem(title: "Item 2", selected: false) + FormPickerItem(title: "Item 3", selected: false) } } } diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeaderView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeader.swift similarity index 74% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeaderView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeader.swift index 7f6c12664..00766b83c 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeaderView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/FormSectionHeader.swift @@ -17,7 +17,7 @@ import SwiftUI @available(iOS 14.0, *) -struct FormSectionHeaderView: View { +struct FormSectionHeader: View { @Environment(\.theme) var theme: Theme var text: String @@ -30,17 +30,18 @@ struct FormSectionHeaderView: View { .padding(.bottom, 8) .font(Font(theme.fonts.subheadline)) .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) } } @available(iOS 14.0, *) struct FormSectionHeader_Previews: PreviewProvider { static var previews: some View { - VectorFormView { - SwiftUI.Section(header: FormSectionHeaderView(text: "Section Header")) { - FormPickerItemView(title: "Item 1", selected: false) - FormPickerItemView(title: "Item 2", selected: false) - FormPickerItemView(title: "Item 3", selected: false) + VectorForm { + SwiftUI.Section(header: FormSectionHeader(text: "Section Header")) { + FormPickerItem(title: "Item 1", selected: false) + FormPickerItem(title: "Item 2", selected: false) + FormPickerItem(title: "Item 3", selected: false) } } } diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettings.swift similarity index 53% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettings.swift index 189faa180..812529837 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettings.swift @@ -1,4 +1,4 @@ -// +// // Copyright 2021 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,7 +17,7 @@ import SwiftUI @available(iOS 14.0.0, *) -struct RoomNotificationSettingsView: View { +struct RoomNotificationSettings: View { @Environment(\.theme) var theme: Theme @ObservedObject var viewModel: RoomNotificationSettingsViewModel @@ -32,7 +32,8 @@ struct RoomNotificationSettingsView: View { } } } - + + @ViewBuilder private var rightButton: some View { Button(VectorL10n.save) { viewModel.process(viewAction: .save) @@ -40,70 +41,57 @@ struct RoomNotificationSettingsView: View { } var body: some View { - VectorFormView { - if let image = viewModel.viewState.avatar { - RoomNotificationSettingsHeaderView(image: image, displayName: viewModel.viewState.displayName) + VectorForm { + if case let .swiftUI(avatarData) = viewModel.viewState.avatarData { + RoomNotificationSettingsHeader( + avatarData: avatarData, + displayName: viewModel.viewState.displayName + ) } SwiftUI.Section( - header: FormSectionHeaderView(text: VectorL10n.roomNotifsSettingsNotifyMeFor), - footer: FormSectionFooterView(text: viewModel.viewState.roomEncryptedString) + header: FormSectionHeader(text: VectorL10n.roomNotifsSettingsNotifyMeFor), + footer: FormSectionFooter(text: viewModel.viewState.roomEncryptedString) ) { ForEach(viewModel.viewState.notificationOptions) { option in - FormPickerItemView(title: option.title, selected: viewModel.viewState.notificationState == option) { + FormPickerItem(title: option.title, selected: viewModel.viewState.notificationState == option) { viewModel.process(viewAction: .selectNotificationState(option)) } } } + .navigationBarTitle(VectorL10n.roomDetailsNotifs) + .navigationBarItems( + leading: leftButton, + trailing: rightButton + ) + .onAppear { + viewModel.process(viewAction: .load) + } } - .navigationBarTitle(VectorL10n.roomDetailsNotifs) - .navigationBarItems( - leading: leftButton, - trailing: rightButton - ).onAppear { - viewModel.process(viewAction: .load) - } - } -} - -fileprivate extension RoomNotificationState { - var title: String { - switch self { - case .all: - return VectorL10n.roomNotifsSettingsAllMessages - case .mentionsAndKeywordsOnly: - return VectorL10n.roomNotifsSettingsMentionsAndKeywords - case .mute: - return VectorL10n.roomNotifsSettingsNone - } - } -} - -fileprivate extension RoomNotificationSettingsViewState { - var roomEncryptedString: String { - roomEncrypted ? VectorL10n.roomNotifsSettingsEncryptedRoomNotice : "" } } @available(iOS 14.0, *) -struct RoomNotificationSettingsView_Previews: PreviewProvider { - +struct RoomNotificationSettings_Previews: PreviewProvider { + static let mockViewModel = RoomNotificationSettingsViewModel( roomNotificationService: MockRoomNotificationSettingsService.example, - avatarService: MockAvatarService.example, - avatarData: .swiftUI(AvatarInput(mxContentUri: nil, itemId: "", displayName: "Alice")), + avatarData: .swiftUI(MockAvatarInput.example), + displayName: MockAvatarInput.example.displayName, roomEncrypted: true ) - + static var previews: some View { Group { NavigationView { - RoomNotificationSettingsView(viewModel: mockViewModel, presentedModally: true) + RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true) .navigationBarTitleDisplayMode(.inline) + .addDependency(MockAvatarService.example) } NavigationView { - RoomNotificationSettingsView(viewModel: mockViewModel, presentedModally: true) + RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true) .navigationBarTitleDisplayMode(.inline) .theme(ThemeIdentifier.dark.theme) + .addDependency(MockAvatarService.example) } } } diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeaderView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeader.swift similarity index 79% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeaderView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeader.swift index 78119c4d2..99caef615 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeaderView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/RoomNotificationSettingsHeader.swift @@ -17,17 +17,17 @@ import SwiftUI @available(iOS 14.0, *) -struct RoomNotificationSettingsHeaderView: View { +struct RoomNotificationSettingsHeader: View { @Environment(\.theme) var theme: Theme - var image: UIImage + var avatarData: AvatarInputType var displayName: String? var body: some View { HStack { Spacer() VStack(alignment: .center) { - VectorAvatarView(image: image, size: .xxLarge) + AvatarImage(avatarData: avatarData, size: .xxLarge) if let displayName = displayName { Text(displayName) .font(Font(theme.fonts.title3SB)) @@ -43,10 +43,11 @@ struct RoomNotificationSettingsHeaderView: View { } @available(iOS 14.0, *) -struct RoomNotificationSettingsHeaderView_Previews: PreviewProvider { +struct RoomNotificationSettingsHeader_Previews: PreviewProvider { static let image = UIImage(imageLiteralResourceName: "app_symbol") static let name = "Element" static var previews: some View { - RoomNotificationSettingsHeaderView(image: image, displayName: name) + RoomNotificationSettingsHeader(avatarData: MockAvatarInput.example, displayName: name) + .addDependency(MockAvatarService.example) } } diff --git a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormView.swift b/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorForm.swift similarity index 75% rename from Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormView.swift rename to Riot/Modules/Room/NotificationSettings/SwiftUI/VectorForm.swift index 170055c3e..31a5dff8e 100644 --- a/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorFormView.swift +++ b/Riot/Modules/Room/NotificationSettings/SwiftUI/VectorForm.swift @@ -17,7 +17,7 @@ import SwiftUI @available(iOS 14.0, *) -struct VectorFormView: View { +struct VectorForm: View { @Environment(\.theme) var theme: Theme var content: () -> Content @@ -48,15 +48,15 @@ struct VectorForm_Previews: PreviewProvider { static var previews: some View { Group { - VectorFormView { - SwiftUI.Section(header: FormSectionHeaderView(text: "Section Header")) { - FormPickerItemView(title: "Item 1", selected: true) - FormPickerItemView(title: "Item 2", selected: false) - FormPickerItemView(title: "Item 3", selected: false) + VectorForm { + SwiftUI.Section(header: FormSectionHeader(text: "Section Header")) { + FormPickerItem(title: "Item 1", selected: true) + FormPickerItem(title: "Item 2", selected: false) + FormPickerItem(title: "Item 3", selected: false) } } - VectorFormView { - FormPickerItemView(title: "Item 1", selected: true) + VectorForm { + FormPickerItem(title: "Item 1", selected: true) }.theme(ThemeIdentifier.dark.theme) } } diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsViewController.swift b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsViewController.swift index 06171bc49..d9135c0c7 100644 --- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsViewController.swift +++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsViewController.swift @@ -144,7 +144,7 @@ final class RoomNotificationSettingsViewController: UIViewController { activityPresenter.removeCurrentActivityIndicator(animated: true) } self.viewState = viewState - if let avatarData = viewState.legacyAvatarData { + if case let .uiKit(avatarData) = viewState.avatarData { mainTableView.tableHeaderView = avatarView avatarView.configure(viewData: avatarData) avatarView.update(theme: theme) diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationsSettingsCell.swift b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationsSettingsCell.swift index 343c7bba4..782e08150 100644 --- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationsSettingsCell.swift +++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationsSettingsCell.swift @@ -41,16 +41,3 @@ extension RoomNotificationSettingsCell: Themable { selectedBackgroundView?.backgroundColor = theme.selectedBackgroundColor } } - -fileprivate extension RoomNotificationState { - var title: String { - switch self { - case .all: - return VectorL10n.roomNotifsSettingsAllMessages - case .mentionsAndKeywordsOnly: - return VectorL10n.roomNotifsSettingsMentionsAndKeywords - case .mute: - return VectorL10n.roomNotifsSettingsNone - } - } -}