Session overview screen

This commit is contained in:
Aleksandrs Proskurins
2022-09-23 17:16:18 +03:00
parent db2bccc7be
commit 6fc2d397d9
18 changed files with 545 additions and 29 deletions
@@ -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?()
}
}
}