Session overview screen

This commit is contained in:
Aleksandrs Proskurins
2022-09-23 17:16:18 +03:00
parent 75f99801ef
commit b86c2ca150
18 changed files with 545 additions and 29 deletions
@@ -18,7 +18,6 @@ import SwiftUI
import CommonKit
struct UserSessionDetailsCoordinatorParameters {
let session: MXSession
let userSessionInfo: UserSessionInfo
}
@@ -33,7 +33,7 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions
static var allCases: [MockUserSessionDetailsScreenState] {
// Each of the presence statuses
return [.allSections, sessionSectionOnly]
return [.allSections, .sessionSectionOnly]
}
/// Generate the view struct for the screen state.
@@ -0,0 +1,77 @@
//
// 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 CommonKit
struct UserSessionOverviewCoordinatorParameters {
let userSessionInfo: UserSessionInfo
let isCurrentSession: Bool
}
final class UserSessionOverviewCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: UserSessionOverviewCoordinatorParameters
private let userSessionOverviewHostingController: UIViewController
private var userSessionOverviewViewModel: UserSessionOverviewViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSessionOverviewCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: UserSessionOverviewCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserSessionOverviewViewModel(userSessionInfo: parameters.userSessionInfo, isCurrentSession: parameters.isCurrentSession)
let view = UserSessionOverview(viewModel: viewModel.context)
userSessionOverviewViewModel = viewModel
userSessionOverviewHostingController = VectorHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionOverviewHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[UserSessionOverviewCoordinator] did start.")
userSessionOverviewViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[UserSessionOverviewCoordinator] UserSessionOverviewViewModel did complete with result: \(result).")
switch result {
case .verifyCurrentSession:
break // TODO
case let .showCurrentSessionDetails(sessionInfo: sessionInfo):
self.completion?(.openSessionDetails(session: sessionInfo))
}
}
}
func toPresentable() -> UIViewController {
return self.userSessionOverviewHostingController
}
// MARK: - Private
}
@@ -0,0 +1,69 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case currentSession
case otherSession
/// The associated screen
var screenType: Any.Type {
UserSessionOverview.self
}
/// A list of screen state definitions
static var allCases: [MockUserSessionOverviewScreenState] {
// Each of the presence statuses
return [.currentSession, .otherSession]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: UserSessionOverviewViewModel
switch self {
case .currentSession:
let currentSessionInfo = UserSessionInfo(sessionId: "session",
sessionName: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100)
viewModel = UserSessionOverviewViewModel(userSessionInfo: currentSessionInfo, isCurrentSession: true)
case .otherSession:
let currentSessionInfo = UserSessionInfo(sessionId: "session",
sessionName: "Mac",
deviceType: .desktop,
isVerified: true,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100)
viewModel = UserSessionOverviewViewModel(userSessionInfo: currentSessionInfo, isCurrentSession: false)
}
// can simulate service and viewModel actions here if needs be.
return (
[viewModel],
AnyView(UserSessionOverview(viewModel: viewModel.context))
)
}
}
@@ -0,0 +1,57 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
class UserSessionOverviewUITests: MockScreenTestCase {
func testUserSessionOverviewPresenceIdle() {
let presence = UserSessionOverviewPresence.idle
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.presence(presence).title)
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func testUserSessionOverviewPresenceOffline() {
let presence = UserSessionOverviewPresence.offline
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.presence(presence).title)
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func testUserSessionOverviewPresenceOnline() {
let presence = UserSessionOverviewPresence.online
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.presence(presence).title)
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func testUserSessionOverviewLongName() {
let name = "Somebody with a super long name we would like to test"
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.longDisplayName(name).title)
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
}
}
@@ -0,0 +1,56 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import Combine
@testable import RiotSwiftUI
class UserSessionOverviewViewModelTests: XCTestCase {
private enum Constants {
static let presenceInitialValue: UserSessionOverviewPresence = .offline
static let displayName = "Alice"
}
var service: MockUserSessionOverviewService!
var viewModel: UserSessionOverviewViewModelProtocol!
var context: UserSessionOverviewViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockUserSessionOverviewService(displayName: Constants.displayName, presence: Constants.presenceInitialValue)
viewModel = UserSessionOverviewViewModel.makeUserSessionOverviewViewModel(userSessionOverviewService: service)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.displayName, Constants.displayName)
XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue)
}
func testFirstPresenceReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first()
XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue])
}
func testPresenceUpdatesReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first()
let awaitDeferred = xcAwaitDeferred(presencePublisher)
let newPresenceValue1: UserSessionOverviewPresence = .online
let newPresenceValue2: UserSessionOverviewPresence = .idle
service.simulateUpdate(presence: newPresenceValue1)
service.simulateUpdate(presence: newPresenceValue2)
XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2])
}
}
@@ -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
// MARK: - Coordinator
enum UserSessionOverviewCoordinatorResult {
case openSessionDetails(session: UserSessionInfo)
}
// MARK: View model
enum UserSessionOverviewViewModelResult {
case showCurrentSessionDetails(sessionInfo: UserSessionInfo)
case verifyCurrentSession
}
// MARK: View
struct UserSessionOverviewViewState: BindableState {
let cardViewData: UserSessionCardViewData
let isCurrentSession: Bool
}
enum UserSessionOverviewViewAction {
case verifyCurrentSession
case viewSessionDetails
}
@@ -0,0 +1,53 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState,
Never,
UserSessionOverviewViewAction>
class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessionOverviewViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let userSessionInfo: UserSessionInfo
// MARK: Public
var completion: ((UserSessionOverviewViewModelResult) -> Void)?
// MARK: - Setup
init(userSessionInfo: UserSessionInfo, isCurrentSession: Bool) {
self.userSessionInfo = userSessionInfo
let cardViewData = UserSessionCardViewData(userSessionInfo: userSessionInfo, isCurrentSessionDisplayMode: isCurrentSession)
let state = UserSessionOverviewViewState(cardViewData: cardViewData, isCurrentSession: isCurrentSession)
super.init(initialViewState: state)
}
// MARK: - Public
override func process(viewAction: UserSessionOverviewViewAction) {
switch viewAction {
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .viewSessionDetails:
completion?(.showCurrentSessionDetails(sessionInfo: userSessionInfo))
}
}
}
@@ -0,0 +1,23 @@
//
// 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 UserSessionOverviewViewModelProtocol {
var completion: ((UserSessionOverviewViewModelResult) -> Void)? { get set }
var context: UserSessionOverviewViewModelType.Context { get }
}
@@ -0,0 +1,73 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct UserSessionOverview: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: UserSessionOverviewViewModel.Context
var body: some View {
ScrollView {
UserSessionCardView(viewData: viewModel.viewState.cardViewData,
onVerifyAction: { _ in
viewModel.send(viewAction: .verifyCurrentSession)
},
onViewDetailsAction: { _ in
viewModel.send(viewAction: .viewSessionDetails)
})
.padding(16)
SwiftUI.Section {
UserSessionOverviewDisclosureCell(title: "Session details", onBackgroundTap: {
viewModel.send(viewAction: .viewSessionDetails)
})
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.navigationTitle(viewModel.viewState.isCurrentSession ? "Current session" : "Session")
}
}
struct SeparatorLine: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
var body: some View {
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(maxWidth: .infinity, alignment: .trailing)
.frame(height: 1.0)
}
}
// MARK: - Previews
struct UserSessionOverview_Previews: PreviewProvider {
static let stateRenderer = MockUserSessionOverviewScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark)
}
}
@@ -0,0 +1,45 @@
//
// Copyright 2022 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
struct UserSessionOverviewDisclosureCell: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let title: String
var onBackgroundTap: (() -> (Void))? = nil
var body: some View {
VStack(spacing: 0) {
SeparatorLine()
HStack() {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
SeparatorLine()
}
.background(theme.colors.background)
.onTapGesture {
onBackgroundTap?()
}
}
}
@@ -48,7 +48,6 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
private func pushScreen(with coordinator: Coordinator & Presentable) {
add(childCoordinator: coordinator)
self.navigationRouter.push(coordinator, animated: true, popCompletion: { [weak self] in
self?.remove(childCoordinator: coordinator)
})
@@ -63,8 +62,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
coordinator.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case let .openSessionDetails(session: session):
self.openSessionDetails(session: session)
case let .openSessionOverview(session: session, isCurrentSession: isCurrentSession):
self.openSessionOverview(session: session, isCurrentSession: isCurrentSession)
}
}
return coordinator
@@ -76,12 +75,27 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
}
private func createUserSessionDetailsCoordinator(session: UserSessionInfo) -> UserSessionDetailsCoordinator {
let parameters = UserSessionDetailsCoordinatorParameters(
session: parameters.session,
userSessionInfo: session)
let parameters = UserSessionDetailsCoordinatorParameters(userSessionInfo: session)
return UserSessionDetailsCoordinator(parameters: parameters)
}
private func openSessionOverview(session: UserSessionInfo, isCurrentSession: Bool) {
let coordinator = createUserSessionOverviewCoordinator(session: session, isCurrentSession: isCurrentSession)
coordinator.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case let .openSessionDetails(session: session):
self.openSessionDetails(session: session)
}
}
pushScreen(with: coordinator)
}
private func createUserSessionOverviewCoordinator(session: UserSessionInfo, isCurrentSession: Bool) -> UserSessionOverviewCoordinator {
let parameters = UserSessionOverviewCoordinatorParameters(userSessionInfo: session, isCurrentSession: isCurrentSession)
return UserSessionOverviewCoordinator(parameters: parameters)
}
// MARK: - Public
func start() {
@@ -72,12 +72,12 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
self.showAllInactiveSessions()
case .verifyCurrentSession:
self.startVerifyCurrentSession()
case .showCurrentSessionDetails:
self.showCurrentSessionDetails()
case let .showCurrentSessionOverview(sessionInfo):
self.showCurrentSessionOverview(sessionInfo: sessionInfo)
case .showAllOtherSessions:
self.showAllOtherSessions()
case .showUserSessionDetails(let sessionId):
self.showUserSessionDetails(sessionId: sessionId)
case let .showUserSessionOverview(sessionInfo):
self.showUserSessionOverview(sessionInfo: sessionInfo)
}
}
}
@@ -113,15 +113,12 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
// TODO
}
private func showCurrentSessionDetails() {
// TODO
private func showCurrentSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(session: sessionInfo, isCurrentSession: true))
}
private func showUserSessionDetails(sessionId: String) {
guard let sessionInfo = service.getOtherSession(sessionId: sessionId) else {
return
}
completion?(.openSessionDetails(session: sessionInfo))
private func showUserSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(session: sessionInfo, isCurrentSession: false))
}
private func showAllOtherSessions() {
@@ -17,13 +17,17 @@
import Foundation
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
var lastOverviewData: UserSessionsOverviewData
func fetchUserSessionsOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
completion(.success(self.lastOverviewData))
}
func getOtherSession(sessionId: String) -> UserSessionInfo? {
nil
}
init() {
let currentSessionInfo = UserSessionInfo(sessionId: "alice", sessionName: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil)
@@ -29,4 +29,6 @@ protocol UserSessionsOverviewServiceProtocol {
var lastOverviewData: UserSessionsOverviewData { get }
func fetchUserSessionsOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) -> Void
func getOtherSession(sessionId: String) -> UserSessionInfo?
}
@@ -19,7 +19,7 @@ import Foundation
// MARK: - Coordinator
enum UserSessionsOverviewCoordinatorResult {
case openSessionDetails(session: UserSessionInfo)
case openSessionOverview(session: UserSessionInfo, isCurrentSession: Bool)
}
// MARK: View model
@@ -28,9 +28,9 @@ enum UserSessionsOverviewViewModelResult {
case showAllUnverifiedSessions
case showAllInactiveSessions
case verifyCurrentSession
case showCurrentSessionDetails
case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
case showAllOtherSessions
case showUserSessionDetails(_ sessionId: String)
case showUserSessionOverview(sessionInfo: UserSessionInfo)
}
// MARK: View
@@ -53,7 +53,11 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
case .verifyCurrentSession:
self.completion?(.verifyCurrentSession)
case .viewCurrentSessionDetails:
self.completion?(.showCurrentSessionDetails)
guard let currentSessionInfo = userSessionsOverviewService.lastOverviewData.currentSessionInfo else {
assertionFailure("currentSessionInfo should be present")
return
}
self.completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo))
case .viewAllUnverifiedSessions:
self.completion?(.showAllUnverifiedSessions)
case .viewAllInactiveSessions:
@@ -61,7 +65,11 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
case .viewAllOtherSessions:
self.completion?(.showAllOtherSessions)
case .tapUserSession(let sessionId):
self.completion?(.showUserSessionDetails(sessionId))
guard let sessionInfo = userSessionsOverviewService.getOtherSession(sessionId: sessionId) else {
assertionFailure("missing session info")
return
}
self.completion?(.showUserSessionOverview(sessionInfo: sessionInfo))
}
}
@@ -64,10 +64,7 @@ struct UserSessionListItem: View {
// Separator
// Note: Separator leading is matching the text leading, we could use alignment guide in the future
Rectangle()
.fill(theme.colors.quinaryContent)
.frame(maxWidth: .infinity, alignment: .trailing)
.frame(height: 1.0)
SeparatorLine()
.padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
}
.padding(.top, LayoutConstants.verticalPadding)