Add Simple Template Example

- Add a simple Template example that shows a user profile with avatar, displayName and presence.
- ScreenCoordinator: closure based with less protocols and delegates.
- Reducer: Reducer function that manages all state modifications.
- SwiftUI View: Decomposes UI into appropriate sub components.
- Uses Theme and Dependency Management Infrastructure
This commit is contained in:
David Langley
2021-09-06 17:12:31 +01:00
parent 495f903a3c
commit 932ee0ac07
18 changed files with 438 additions and 31 deletions
@@ -0,0 +1,75 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Foundation
import UIKit
import SwiftUI
final class TemplateUserProfileCoordinator: Coordinator {
typealias Completion = () -> Void
// MARK: - Properties
// MARK: Private
private let session: MXSession
private let templateUserProfileViewController: UIViewController
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: Completion?
// MARK: - Setup
@available(iOS 14.0, *)
init(session: MXSession) {
self.session = session
let hostViewController = VectorHostingController()
templateUserProfileViewController = UINavigationController(rootViewController: hostViewController)
let rootView = TemplateUserProfile.instantiate(session: session, completion: self.userProfileCompletion(result:))
hostViewController.setRoot(view: rootView)
}
@available(iOS 14.0, *)
func userProfileCompletion(result: TemplateUserProfile.Result) {
switch result {
case .cancel, .done:
completion?()
break
}
}
// MARK: - Public methods
func start() {
}
func toPresentable() -> UIViewController {
return self.templateUserProfileViewController
}
}
@available(iOS 14.0, *)
extension TemplateUserProfile {
static func instantiate(session: MXSession, completion: @escaping TemplateUserProfile.Completion) -> some View {
let templateUserProfileViewModel = TemplateUserProfileViewModel(userService: MXTemplateUserService(session: session))
let templateUserProfile = TemplateUserProfile(viewModel: templateUserProfileViewModel, completion: completion)
return templateUserProfile.addDependency(MXAvatarService.instantiate(mediaManager: session.mediaManager))
}
}
@@ -0,0 +1,42 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum TemplatePresence {
case online
case idle
case offline
}
extension TemplatePresence {
var title: String {
switch self {
case .online:
return VectorL10n.roomParticipantsOnline
case .idle:
return VectorL10n.roomParticipantsIdle
case .offline:
return VectorL10n.roomParticipantsOffline
}
}
}
extension TemplatePresence: CaseIterable { }
extension TemplatePresence: Identifiable {
var id: Self { self }
}
@@ -16,13 +16,6 @@
import Foundation
struct TemplateMockUserService: TemplateUserServiceType {
static let example = TemplateMockUserService(userId: "123", displayName: "Alice", avatarUrl: "mx123@matrix.com", currentlyActive: true, lastActive: 123456)
let userId: String
let displayName: String?
let avatarUrl: String?
let currentlyActive: Bool
let lastActive: UInt
enum TemplateProfileStateAction {
case updatePresence(TemplatePresence)
}
@@ -19,4 +19,5 @@ import Foundation
struct TemplateUserProfileViewState {
let avatar: AvatarInputType?
let displayName: String?
var presence: TemplatePresence = .offline
}
@@ -0,0 +1,76 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
class MXTemplateUserService: TemplateUserServiceType {
let session: MXSession
var listenerReference: Any!
@Published var presence: TemplatePresence = .offline
init(session: MXSession) {
self.session = session
let listenerReference = session.myUser.listen { [weak self] event in
guard let self = self,
let event = event,
case .presence = MXEventType(identifier: event.eventId)
else { return }
self.presence = TemplatePresence(mxPresence: self.session.myUser.presence)
}
self.listenerReference = listenerReference
}
var userId: String {
return session.myUser.userId
}
var displayName: String? {
session.myUser.displayname
}
var avatarUrl: String? {
session.myUser.avatarUrl
}
var presencePublisher: AnyPublisher<TemplatePresence, Never> {
$presence.eraseToAnyPublisher()
}
deinit {
session.myUser.removeListener(listenerReference)
}
}
fileprivate extension TemplatePresence {
init(mxPresence: MXPresence) {
switch mxPresence {
case MXPresenceOnline:
self = .online
case MXPresenceUnavailable:
self = .idle
case MXPresenceOffline, MXPresenceUnknown:
self = .offline
default:
self = .offline
}
}
}
@@ -0,0 +1,33 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
class MockTemplateUserService: TemplateUserServiceType, ObservableObject {
static let example = MockTemplateUserService()
@Published var presence: TemplatePresence = .online
var presencePublisher: AnyPublisher<TemplatePresence, Never> {
$presence.eraseToAnyPublisher()
}
let userId: String = "123"
let displayName: String? = "Alice"
let avatarUrl: String? = "mx123@matrix.com"
let currentlyActive: Bool = true
let lastActive: UInt = 1630596918513
}
@@ -15,15 +15,17 @@
//
import Foundation
import Combine
@available(iOS 14.0, *)
protocol TemplateUserServiceType: Avatarable {
var userId: String { get }
var displayName: String? { get }
var avatarUrl: String? { get }
var currentlyActive: Bool { get }
var lastActive: UInt { get }
var presencePublisher: AnyPublisher<TemplatePresence, Never> { get }
}
@available(iOS 14.0, *)
extension TemplateUserServiceType {
var mxContentUri: String? {
avatarUrl
@@ -0,0 +1,59 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct TemplatePresenceView: View {
let presense: TemplatePresence
var foregroundColor: Color {
switch presense {
case .online:
return .green
case .idle:
return .orange
case .offline:
return .gray
}
}
var body: some View {
HStack {
Image(systemName: "circle.fill")
.resizable()
.frame(width: 8, height: 8)
.foregroundColor(foregroundColor)
Text(presense.title)
.font(.subheadline)
}
.foregroundColor(foregroundColor)
.padding(0)
}
}
@available(iOS 14.0, *)
struct TemplatePresenceView_Previews: PreviewProvider {
static var previews: some View {
VStack(alignment:.leading){
Text("Presence")
ForEach(TemplatePresence.allCases) { presence in
TemplatePresenceView(presense: presence)
}
}
}
}
@@ -19,32 +19,61 @@ import SwiftUI
@available(iOS 14.0, *)
struct TemplateUserProfile: View {
enum Result {
case cancel
case done
}
typealias Completion = (Result) -> Void
@Environment(\.theme) var theme: ThemeSwiftUI
@ObservedObject var viewModel: TemplateUserProfileViewModel
var header: some View {
var completion: Completion
var leftButton: some View {
Button(VectorL10n.cancel) {
completion(.cancel)
}
}
var rightButton: some View {
Button(VectorL10n.done) {
completion(.done)
}
}
var body: some View {
VStack {
if let avatar = viewModel.viewState.avatar {
HStack{
TemplateUserProfileHeader(
avatar: viewModel.viewState.avatar,
displayName: viewModel.viewState.displayName,
presence: viewModel.viewState.presence
)
Divider()
VStack{
HStack(alignment: .center){
Spacer()
AvatarImage(avatarData: avatar, size: .xxLarge)
Text("More great user content!")
.font(theme.fonts.title2)
.foregroundColor(theme.colors.secondaryContent)
Spacer()
}
}
Text(viewModel.viewState.displayName ?? "")
}
}
var body: some View {
VectorForm {
header
.frame(maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
.navigationTitle(viewModel.viewState.displayName ?? "")
.navigationBarItems(leading: leftButton, trailing: rightButton)
}
}
@available(iOS 14.0, *)
struct TemplateUserProfile_Previews: PreviewProvider {
static var previews: some View {
TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: TemplateMockUserService.example))
.addDependency(MockAvatarService.example)
TemplateUserProfile(viewModel: TemplateUserProfileViewModel(userService: MockTemplateUserService.example)) { _ in
}
.addDependency(MockAvatarService.example)
}
}
@@ -0,0 +1,52 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct TemplateUserProfileHeader: View {
@Environment(\.theme) var theme: ThemeSwiftUI
let avatar: AvatarInputType?
let displayName: String?
let presence: TemplatePresence
var body: some View {
VStack {
if let avatar = avatar {
HStack{
Spacer()
AvatarImage(avatarData: avatar, size: .xxLarge)
Spacer()
}
.padding(.vertical)
}
VStack(spacing: 8){
Text(displayName ?? "")
.font(theme.fonts.title3)
TemplatePresenceView(presense: presence)
}
}
}
}
@available(iOS 14.0, *)
struct TemplateUserProfileHeader_Previews: PreviewProvider {
static var previews: some View {
TemplateUserProfileHeader(avatar: MockAvatarInput.example, displayName: "Alice", presence: .online)
.addDependency(MockAvatarService.example)
}
}
@@ -15,13 +15,15 @@
//
import SwiftUI
import Combine
@available(iOS 14.0, *)
class TemplateUserProfileViewModel: ObservableObject {
private let userService: TemplateUserServiceType
private var cancellables = Set<AnyCancellable>()
@Published var viewState: TemplateUserProfileViewState
@Published private(set) var viewState: TemplateUserProfileViewState
private static func defaultState(userService: TemplateUserServiceType) -> TemplateUserProfileViewState {
return TemplateUserProfileViewState(avatar: userService.avatarData, displayName: userService.displayName)
@@ -30,5 +32,30 @@ class TemplateUserProfileViewModel: ObservableObject {
init(userService: TemplateUserServiceType, initialState: TemplateUserProfileViewState? = nil) {
self.userService = userService
self.viewState = initialState ?? Self.defaultState(userService: userService)
userService.presencePublisher
.map(TemplateProfileStateAction.updatePresence)
.receive(on: DispatchQueue.main)
.sink(receiveValue: self.dispatch(action:))
.store(in: &cancellables)
}
/**
Send state actions to mutate the state.
*/
private func dispatch(action: TemplateProfileStateAction) {
var newState = self.viewState
reducer(state: &newState, action: action)
self.viewState = newState
}
/**
A redux style reducer, all modifications to state happen here. Recieves a state and a state action and produces a new state.
*/
private func reducer(state: inout TemplateUserProfileViewState, action: TemplateProfileStateAction) {
switch action {
case .updatePresence(let presence):
state.presence = presence
}
}
}