Add theming support, and support for other top level configuration to swiftui views with VectorHostingViewController, VectorContentModifier. Add VisibilityModifier. Move from List to VStack.

This commit is contained in:
David Langley
2021-08-12 11:52:06 +01:00
parent 9570a9422b
commit 9ff69eaaf0
29 changed files with 841 additions and 213 deletions

View File

@@ -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
import UIKit
// Figma Avatar Sizes: https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=1258%3A19678
public enum AvatarSize: Int {
case xxSmall = 16
case xSmall = 32
case small = 36
case medium = 42
case large = 44
case xLarge = 52
case xxLarge = 80
}
extension AvatarSize {
public var size: CGSize {
return CGSize(width: self.rawValue, height: self.rawValue)
}
}

View File

@@ -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
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 {
func weakAssign<T: AnyObject>(
to keyPath: ReferenceWritableKeyPath<T, Output>,
on object: T
) -> AnyCancellable {
sink { [weak object] value in
object?[keyPath: keyPath] = value
}
}
}

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
import SwiftUI
private struct ThemeKey: EnvironmentKey {
static let defaultValue = ThemeService.shared().theme
}
@available(iOS 14.0, *)
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
@available(iOS 14.0, *)
extension View {
func theme(_ theme: Theme) -> some View {
environment(\.theme, theme)
}
}

View File

@@ -17,21 +17,15 @@
import Foundation
import Combine
@available(iOS 13.0, *)
class ThemeServiceObserver: ObservableObject {
@available(iOS 14.0, *)
class ThemeObserver: ObservableObject {
static let shared = ThemeServiceObserver()
var cancelable: Cancellable?
static let shared = ThemeObserver()
init() {
let themePubliser = NotificationCenter.default.publisher(for: NSNotification.Name.themeServiceDidChangeTheme).map { _ in
ThemeService.shared().themeIdentifier
}
cancelable = themePubliser.sink { [weak self] id in
guard let self = self else { return }
self.themeId = id
}
NotificationCenter.default.publisher(for: NSNotification.Name.themeServiceDidChangeTheme).map { _ in
ThemeService.shared().theme
}.assign(to: &$theme)
}
@Published var themeId: ThemeIdentifier?
@Published var theme: Theme = ThemeService.shared().theme
}

View File

@@ -40,4 +40,3 @@ extension ThemeIdentifier {
ThemeService.shared().theme(withThemeId: self.rawValue)
}
}

View File

@@ -25,23 +25,3 @@ extension ThemeService {
return ThemeIdentifier(rawValue: themeId)
}
}
import SwiftUI
private struct ThemeKey: EnvironmentKey {
static let defaultValue = ThemeService.shared().theme
}
@available(iOS 13.0, *)
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
@available(iOS 13.0, *)
extension View {
func theme(_ theme: Theme) -> some View {
environment(\.theme, theme)
}
}

View File

@@ -0,0 +1,34 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol AvatarInputType {
var mxContentUri: String? { get }
var itemId: String { get }
var displayName: String? { get }
}
struct AvatarInput: AvatarInputType {
let mxContentUri: String?
let itemId: String
let displayName: String?
}
enum AvatarInputOption {
case swiftUI(AvatarInputType)
case uiKit(AvatarViewDataProtocol)
}

View File

@@ -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 Foundation
import MatrixSDK
import Combine
import DesignKit
/**
Provides a simple api to retrieve and cache avatar images
*/
protocol AvatarServiceType {
@available(iOS 14.0, *)
func avatarImage(inputData: AvatarInputType) -> AnyPublisher<UIImage?, Never>
}
enum AvatarServiceError: Error {
case pathNotfound
case loadingImageFailed(Error?)
}
class AvatarService: AvatarServiceType {
private enum Constants {
static let mimeType = "image/jpeg"
static let thumbnailMethod = MXThumbnailingMethodCrop
static let avatarDownloadSize = AvatarSize.xxLarge.size
}
let avatarGenerator: AvatarGenerator
let mediaManager: MXMediaManager
init(avatarGenerator: AvatarGenerator, mediaManager: MXMediaManager) {
self.avatarGenerator = avatarGenerator
self.mediaManager = mediaManager
}
@available(iOS 14.0, *)
func avatarImage(inputData: AvatarInputType) -> AnyPublisher<UIImage?, Never> {
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()
}
let cachePath = MXMediaManager.thumbnailCachePath(
forMatrixContentURI: mxContentUri,
andType: Constants.mimeType,
inFolder: nil,
toFitViewSize: Constants.avatarDownloadSize,
with: Constants.thumbnailMethod)
if let image = MXMediaManager.loadThroughCache(withFilePath: cachePath) {
// Already cached, complete with the avatar
return Just(Self.orientImageUp(image: image))
.eraseToAnyPublisher()
}
let future = Future<UIImage?, Error> { promise in
self.mediaManager.downloadThumbnail(
fromMatrixContentURI: mxContentUri,
withType: Constants.mimeType,
inFolder: nil,
toFitViewSize: Constants.avatarDownloadSize,
with: Constants.thumbnailMethod) { path in
guard let path = path else {
promise(.failure(AvatarServiceError.pathNotfound))
return
}
let image = MXMediaManager.loadThroughCache(withFilePath: path)
promise(.success(Self.orientImageUp(image: image)))
} failure: { error in
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<UIImage?> 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? {
guard let image = image?.cgImage else { return nil }
return UIImage(cgImage: image, scale: 1.0, orientation: .up)
}
}

View File

@@ -0,0 +1,30 @@
//
// 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 MockAvatarService: AvatarServiceType {
static let example = MockAvatarService()
func avatarImage(inputData: AvatarInputType) -> AnyPublisher<UIImage?, Never> {
guard let image = AvatarGenerator.generateAvatar(forText: inputData.displayName ?? "") else {
fatalError()
}
return Just(image)
.eraseToAnyPublisher()
}
}

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 SwiftUI
/**
A Modifier to be called from the topmost 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 {
@StateObject var themeObservor = ThemeObserver.shared
func body(content: Content) -> some View {
content
.theme(themeObservor.theme)
}
}
@available(iOS 14.0, *)
extension View {
func vectorContent() -> some View {
self.modifier(VectorContentModifier())
}
}

View File

@@ -0,0 +1,64 @@
//
// 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
/**
UIHostingController that applies some level-app specific configuration
(E.g. vectorContent modifier and themeing to the NavigationController container.
*/
@available(iOS 14.0, *)
class VectorHostingViewController: UIHostingController<AnyView> {
init<Content>(rootView: Content) where Content: View {
self.theme = ThemeService.shared().theme
super.init(rootView: AnyView(rootView.vectorContent()))
}
required init?(coder aDecoder: NSCoder) {
fatalError("VectorHostingViewController does not currently support init from nibs")
}
// MARK: Private
private var theme: Theme
// MARK: - Life cycle
override func viewDidLoad() {
super.viewDidLoad()
self.registerThemeServiceDidChangeThemeNotification()
self.update(theme: self.theme)
}
private func registerThemeServiceDidChangeThemeNotification() {
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
}
@objc private func themeDidChange() {
self.update(theme: ThemeService.shared().theme)
}
private func update(theme: Theme) {
self.view.backgroundColor = theme.headerBackgroundColor
if let navigationBar = self.navigationController?.navigationBar {
theme.applyStyle(onNavigationBar: navigationBar)
}
}
}

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 Foundation
import SwiftUI
/**
Used to modify the visibilty of a SwiftUI view.
`hidden` naming historically on iOS refers to a view that is not visible but is included in layout/constraints. i.e. takes up the space.
`gone` here refers to a view that is invisible and does not contribute to layout. Android uses the same naming as this.
*/
@available(iOS 14.0, *)
enum Visbility: Int {
case visible
case hidden
case gone
}
@available(iOS 14.0, *)
struct VisbilityModifier: ViewModifier {
var visibilty: Visbility
func body(content: Content) -> some View {
if visibilty == .visible {
content
} else if visibilty == .hidden {
content.hidden()
}
}
}
@available(iOS 14.0, *)
extension View {
func hidden(_ invisible: Bool) -> some View {
self.modifier(VisbilityModifier(visibilty: invisible ? .hidden : .visible))
}
func gone(_ hidden: Bool) -> some View {
self.modifier(VisbilityModifier(visibilty: hidden ? .gone : .visible))
}
func visbility(_ visibility: Visbility) -> some View {
self.modifier(VisbilityModifier(visibilty: visibility))
}
}

View File

@@ -26,7 +26,6 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin
// MARK: Private
private var roomNotificationSettingsViewModel: RoomNotificationSettingsViewModelType
// private let roomNotificationSettingsViewController: RoomNotificationSettingsViewController
private let roomNotificationSettingsViewController: UIViewController
// MARK: Public
@@ -38,23 +37,37 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin
// MARK: - Setup
init(room: MXRoom, showAvatar: Bool = true) {
let repository = RoomNotificationSettingsService(room: room)
init(room: MXRoom, presentedModally: Bool = true) {
let roomNotificationService = RoomNotificationSettingsService(room: room)
let avatarService = AvatarService(avatarGenerator: AvatarGenerator(), mediaManager: room.mxSession.mediaManager)
let avatarData = showAvatar ? RoomAvatarViewData(
roomId: room.roomId,
displayName: room.summary.displayname,
avatarUrl: room.summary.avatar,
mediaManager: room.mxSession.mediaManager
) : nil
let avatarData: AvatarInputOption?
let showAvatar = presentedModally
if #available(iOS 14.0.0, *) {
avatarData = showAvatar ? .swiftUI(AvatarInput(mxContentUri: room.summary.avatar,
itemId: room.roomId,
displayName: room.summary.displayname
)) : nil
} else {
avatarData = showAvatar ? .uiKit(RoomAvatarViewData(
roomId: room.roomId,
displayName: room.summary.displayname,
avatarUrl: room.summary.avatar,
mediaManager: room.mxSession.mediaManager
)) : nil
}
let roomNotificationSettingsViewModel = RoomNotificationSettingsViewModel(roomNotificationService: repository, roomEncrypted: room.summary.isEncrypted, avatarViewData: avatarData)
let roomNotificationSettingsViewModel = RoomNotificationSettingsViewModel(
roomNotificationService: roomNotificationService,
avatarService: avatarService,
avatarData: avatarData,
roomEncrypted: room.summary.isEncrypted)
let viewController: UIViewController
if #available(iOS 13.0.0, *) {
// let sampleViewState = RoomNotificationSettingsViewState(roomEncrypted: true, saving: false, notificationState: .mute, avatarData: nil)
let view = RoomNotificationSettingsView(viewModel: roomNotificationSettingsViewModel, presentedModally: true)
viewController = UIHostingController(rootView: view)
if #available(iOS 14.0.0, *) {
let view = RoomNotificationSettingsView(viewModel: roomNotificationSettingsViewModel, presentedModally: presentedModally)
.vectorContent()
viewController = VectorHostingViewController(rootView: view)
} else {
viewController = RoomNotificationSettingsViewController.instantiate(with: roomNotificationSettingsViewModel)
}

View File

@@ -18,6 +18,7 @@
import Foundation
import SwiftUI
import Combine
final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModelType {
@@ -26,39 +27,59 @@ final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModel
// MARK: Private
private let roomNotificationService: RoomNotificationSettingsServiceType
private let avatarService: AvatarServiceType
private var state: RoomNotificationSettingsViewState {
willSet {
update(viewState: newValue)
}
}
@available(iOS 13.0.0, *)
@available(iOS 14.0, *)
@Published var viewState: RoomNotificationSettingsViewState!
@available(iOS 14.0, *)
lazy var bag = Set<AnyCancellable>()
// MARK: Public
weak var viewDelegate: RoomNotificationSettingsViewModelViewDelegate?
weak var coordinatorDelegate: RoomNotificationSettingsViewModelCoordinatorDelegate?
// MARK: - Setup
init(roomNotificationService: RoomNotificationSettingsServiceType, roomEncrypted: Bool, avatarViewData: AvatarViewDataProtocol?) {
init(
roomNotificationService: RoomNotificationSettingsServiceType,
avatarService: AvatarServiceType,
avatarData: AvatarInputOption?,
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, avatarData: avatarViewData)
let initialState = RoomNotificationSettingsViewState(roomEncrypted: roomEncrypted, saving: false, notificationState: notificationState, avatar: nil)
self.state = initialState
if #available(iOS 13.0.0, *) {
if #available(iOS 14.0, *) {
self.viewState = initialState
}
self.roomNotificationService.observeNotificationState { [weak self] state in
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: &bag)
}
}
// MARK: - Public
@@ -95,11 +116,11 @@ final class RoomNotificationSettingsViewModel: RoomNotificationSettingsViewModel
private func update(viewState: RoomNotificationSettingsViewState) {
self.viewDelegate?.roomNotificationSettingsViewModel(self, didUpdateViewState: viewState)
if #available(iOS 13.0.0, *) {
if #available(iOS 14.0, *) {
self.viewState = viewState
}
}
}
@available(iOS 13.0, *)
@available(iOS 14.0, *)
extension RoomNotificationSettingsViewModel: ObservableObject {}

View File

@@ -23,6 +23,12 @@ struct RoomNotificationSettingsViewState: RoomNotificationSettingsViewStateType
let roomEncrypted: Bool
var saving: Bool
var notificationState: RoomNotificationState
var avatar: UIImage?
var displayName: String?
var legacyAvatarData: AvatarViewDataProtocol?
}
extension RoomNotificationSettingsViewState {
var notificationOptions: [RoomNotificationState] {
if roomEncrypted {
return [.all, .mute]
@@ -30,7 +36,6 @@ struct RoomNotificationSettingsViewState: RoomNotificationSettingsViewStateType
return RoomNotificationState.allCases
}
}
let avatarData: AvatarViewDataProtocol?
}
protocol RoomNotificationSettingsViewStateType {
@@ -38,5 +43,6 @@ protocol RoomNotificationSettingsViewStateType {
var roomEncrypted: Bool { get }
var notificationOptions: [RoomNotificationState] { get }
var notificationState: RoomNotificationState { get }
var avatarData: AvatarViewDataProtocol? { get }
var avatar: UIImage? { get }
var legacyAvatarData: AvatarViewDataProtocol? { get }
}

View File

@@ -16,8 +16,14 @@
import Foundation
enum RoomNotificationState: CaseIterable {
enum RoomNotificationState: Int {
case all
case mentionsAndKeywordsOnly
case mute
}
extension RoomNotificationState: CaseIterable { }
extension RoomNotificationState: Identifiable {
var id: Int { self.rawValue }
}

View File

@@ -1,59 +0,0 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 13.0, *)
struct FormPickerItem: View {
typealias ClickCallback = () -> Void
@Environment(\.theme) var theme: Theme
var title: String
var selected: Bool
var onClick: ClickCallback?
var body: some View {
HStack {
Text(title)
.font(Font(theme.fonts.body))
.foregroundColor(Color(theme.textPrimaryColor))
Spacer()
if selected {
Image("checkmark")
.foregroundColor(Color(theme.tintColor))
}
}
.listRowBackground(Color(theme.backgroundColor))
.contentShape(Rectangle())
.onTapGesture {
onClick?()
}
}
}
@available(iOS 13.0, *)
struct FormPickerCell_Previews: PreviewProvider {
static var previews: some View {
List {
FormPickerItem(title: "Item 1", selected: true, onClick: nil)
FormPickerItem(title: "Item 2", selected: false, onClick: nil)
FormPickerItem(title: "Item 3", selected: false, onClick: nil)
}.listStyle(GroupedListStyle())
}
}

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 FormPickerItemView: View {
typealias ClickCallback = () -> Void
@Environment(\.theme) var theme: Theme
var title: String
var selected: Bool
var onClick: ClickCallback?
var body: some View {
Button {
onClick?()
} label: {
VStack {
Spacer()
HStack {
Text(title)
Spacer()
Image("checkmark")
.foregroundColor(Color(theme.tintColor))
.gone(!selected)
}
.padding(.trailing)
Spacer()
Divider()
}
.padding(.leading)
}
.buttonStyle(VectorFormItemButtonStyle())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, idealHeight: 44, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
@available(iOS 14.0, *)
struct FormPickerCell_Previews: PreviewProvider {
static let items = ["Item 1", "Item 2", "Item 3"]
static var selected: String = items[0]
static var previews: some View {
VectorFormView {
ForEach(items, id: \.self) { item in
FormPickerItemView(title: item, selected: selected == item)
}
}
}
}

View File

@@ -16,8 +16,8 @@
import SwiftUI
@available(iOS 13.0, *)
struct FormSectionFooter: View {
@available(iOS 14.0, *)
struct FormSectionFooterView: View {
@Environment(\.theme) var theme: Theme
var text: String
@@ -26,19 +26,21 @@ struct FormSectionFooter: View {
Text(text)
.foregroundColor(Color(theme.textSecondaryColor))
.padding(.top)
.padding(.leading)
.padding(.trailing)
.font(Font(theme.fonts.callout))
}
}
@available(iOS 13.0, *)
@available(iOS 14.0, *)
struct FormSectionFooter_Previews: PreviewProvider {
static var previews: some View {
List {
SwiftUI.Section(footer: FormSectionFooter(text: "Footer Text")) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
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)
}
}.listStyle(GroupedListStyle())
}
}
}

View File

@@ -16,8 +16,8 @@
import SwiftUI
@available(iOS 13.0, *)
struct FormSectionHeader: View {
@available(iOS 14.0, *)
struct FormSectionHeaderView: View {
@Environment(\.theme) var theme: Theme
var text: String
@@ -26,19 +26,23 @@ struct FormSectionHeader: View {
Text(text)
.foregroundColor(Color(theme.textSecondaryColor))
.padding(.top)
.padding(.leading)
.padding(.bottom, 8)
.font(Font(theme.fonts.footnote))
.autocapitalization(.allCharacters)
.textCase(.uppercase)
}
}
@available(iOS 13.0, *)
@available(iOS 14.0, *)
struct FormSectionHeader_Previews: PreviewProvider {
static var previews: some View {
List {
SwiftUI.Section(header: FormSectionHeader(text: "Section Header")) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
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)
}
}.listStyle(GroupedListStyle())
}
}
}

View File

@@ -0,0 +1,50 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct RoomNotificationSettingsHeaderView: View {
@Environment(\.theme) var theme: Theme
var image: UIImage
var displayName: String?
var body: some View {
HStack {
Spacer()
VStack(alignment: .center) {
VectorAvatarView(image: image, size: .xxLarge)
if let displayName = displayName {
Text(displayName)
.font(Font(theme.fonts.title3SB))
.foregroundColor(Color(theme.textPrimaryColor))
.textCase(nil)
}
}
Spacer()
}.padding(.top, 36)
}
}
@available(iOS 14.0, *)
struct RoomNotificationSettingsHeaderView_Previews: PreviewProvider {
static let image = UIImage(imageLiteralResourceName: "app_symbol")
static let name = "Element"
static var previews: some View {
RoomNotificationSettingsHeaderView(image: image, displayName: name)
}
}

View File

@@ -16,32 +16,40 @@
import SwiftUI
@available(iOS 13.0.0, *)
@available(iOS 14.0.0, *)
struct RoomNotificationSettingsView: View {
@Environment(\.theme) var theme: Theme
@ObservedObject var viewModel: RoomNotificationSettingsViewModel
let presentedModally: Bool
@State var notificationState: RoomNotificationState = RoomNotificationState.all
var body: some View {
let leftButton = presentedModally ?
@ViewBuilder
var leftButton: some View {
if presentedModally {
SwiftUI.Button(VectorL10n.cancel) {
viewModel.process(viewAction: .cancel)
}
: nil
let rightButton = SwiftUI.Button(VectorL10n.save) {
}
}
var rightButton: some View {
Button(VectorL10n.save) {
viewModel.process(viewAction: .save)
}
VectorForm {
}
var body: some View {
VectorFormView {
if let image = viewModel.viewState.avatar {
RoomNotificationSettingsHeaderView(image: image, displayName: viewModel.viewState.displayName)
}
SwiftUI.Section(
header: FormSectionHeader(text: VectorL10n.roomNotifsSettingsNotifyMeFor),
footer: FormSectionFooter(text: viewModel.viewState.roomEncryptedString)
header: FormSectionHeaderView(text: VectorL10n.roomNotifsSettingsNotifyMeFor),
footer: FormSectionFooterView(text: viewModel.viewState.roomEncryptedString)
) {
ForEach(viewModel.viewState.notificationOptions) { option in
FormPickerItem(title: option.title, selected: viewModel.viewState.notificationState == option) {
FormPickerItemView(title: option.title, selected: viewModel.viewState.notificationState == option) {
viewModel.process(viewAction: .selectNotificationState(option))
}
}
@@ -56,8 +64,6 @@ struct RoomNotificationSettingsView: View {
}
}
}
fileprivate extension RoomNotificationState {
var title: String {
@@ -78,26 +84,27 @@ fileprivate extension RoomNotificationSettingsViewState {
}
}
extension RoomNotificationState: Identifiable {
var id: String { UUID().uuidString }
}
@available(iOS 14.0, *)
struct RoomNotificationSettingsView_Previews: PreviewProvider {
static let mockViewModel = RoomNotificationSettingsViewModel(
roomNotificationService: MockRoomNotificationSettingsService.example,
roomEncrypted: true,
avatarViewData: nil
avatarService: MockAvatarService.example,
avatarData: .swiftUI(AvatarInput(mxContentUri: nil, itemId: "", displayName: "Alice")),
roomEncrypted: true
)
static var previews: some View {
Group {
NavigationView {
RoomNotificationSettingsView(viewModel: mockViewModel, presentedModally: true)
.navigationBarTitleDisplayMode(.inline)
}
NavigationView {
RoomNotificationSettingsView(viewModel: mockViewModel, presentedModally: true)
.navigationBarTitleDisplayMode(.inline)
.theme(ThemeIdentifier.dark.theme)
}
}
}
}

View File

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

View File

@@ -1,47 +0,0 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 13.0, *)
struct VectorForm<Content: View>: View {
@Environment(\.theme) var theme: Theme
var content: () -> Content
var body: some View {
List(content: content)
.listRowBackground(Color(theme.backgroundColor))
.listStyle(GroupedListStyle())
.onAppear {
UITableView.appearance().backgroundColor = theme.baseColor
UINavigationBar.appearance().barTintColor = theme.baseColor
UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: theme.textPrimaryColor]
UINavigationBar.appearance().isTranslucent = false
UINavigationBar.appearance().setBackgroundImage(UIImage(), for: UIBarMetrics.default)
UINavigationBar.appearance().shadowImage = UIImage()
}
}
}
@available(iOS 13.0, *)
struct VectorForm_Previews: PreviewProvider {
static var previews: some View {
VectorForm {
Text("Item 1")
}
}
}

View File

@@ -0,0 +1,29 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
@available(iOS 14.0, *)
struct VectorFormItemButtonStyle: ButtonStyle {
@Environment(\.theme) var theme: Theme
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? Color(theme.selectedBackgroundColor) : Color(theme.backgroundColor))
.foregroundColor(Color(theme.textPrimaryColor))
.font(Font(theme.fonts.body))
}
}

View File

@@ -0,0 +1,63 @@
//
// 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 VectorFormView<Content: View>: View {
@Environment(\.theme) var theme: Theme
var content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0, content: content)
}
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .top
)
.background(Color(theme.baseColor))
.edgesIgnoringSafeArea(.bottom)
}
}
@available(iOS 14.0, *)
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)
}
}
VectorFormView {
FormPickerItemView(title: "Item 1", selected: true)
}.theme(ThemeIdentifier.dark.theme)
}
}
}

View File

@@ -144,7 +144,7 @@ final class RoomNotificationSettingsViewController: UIViewController {
activityPresenter.removeCurrentActivityIndicator(animated: true)
}
self.viewState = viewState
if let avatarData = viewState.avatarData {
if let avatarData = viewState.legacyAvatarData {
mainTableView.tableHeaderView = avatarView
avatarView.configure(viewData: avatarData)
avatarView.update(theme: theme)

View File

@@ -138,7 +138,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType {
}
private func createRoomNotificationSettingsCoordinator() -> RoomNotificationSettingsCoordinator {
let coordinator = RoomNotificationSettingsCoordinator(room: room, showAvatar: false)
let coordinator = RoomNotificationSettingsCoordinator(room: room, presentedModally: false)
coordinator.delegate = self
return coordinator
}

View File

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -64,7 +65,7 @@
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ekk-PI-fnh">
<rect key="frame" x="156" y="97" width="50.5" height="24"/>
<rect key="frame" x="156" y="97" width="50" height="24"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="20"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
@@ -87,7 +88,7 @@
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalHuggingPriority="252" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" editable="NO" text="Lorem ipsum" translatesAutoresizingMaskIntoConstraints="NO" id="DCx-MH-TSC" customClass="ReadMoreTextView" customModule="ReadMoreTextView">
<rect key="frame" x="0.0" y="36" width="362" height="34"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
<dataDetectorType key="dataDetectorTypes" phoneNumber="YES" link="YES" address="YES" calendarEvent="YES" shipmentTrackingNumber="YES" flightNumber="YES"/>
@@ -170,4 +171,14 @@
<point key="canvasLocation" x="155.07246376811597" y="-254.12946428571428"/>
</view>
</objects>
<designables>
<designable name="DCx-MH-TSC">
<size key="intrinsicContentSize" width="-1" height="34"/>
</designable>
</designables>
<resources>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>