User session flow coordinator and details view

This commit is contained in:
Aleksandrs Proskurins 2022-09-15 09:46:23 +03:00
parent 75bd23d94a
commit a06d7d0aaa
10 changed files with 439 additions and 6 deletions

View file

@ -0,0 +1,57 @@
//
// 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
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case allSections
case sessionSectionOnly
/// The associated screen
var screenType: Any.Type {
UserSessionDetailsView.self
}
/// A list of screen state definitions
static var allCases: [MockUserSessionDetailsScreenState] {
// Each of the presence statuses
return [.allSections, sessionSectionOnly]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let currentSessionInfo: UserSessionInfo
switch self {
case .allSections:
currentSessionInfo = UserSessionInfo(sessionId: "session", sessionName: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: Date().timeIntervalSince1970 - 100)
case .sessionSectionOnly:
currentSessionInfo = UserSessionInfo(sessionId: "session", sessionName: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 100)
}
let viewModel = UserSessionDetailsViewModel(userSessionInfo: currentSessionInfo)
// can simulate service and viewModel actions here if needs be.
return (
[currentSessionInfo],
AnyView(UserSessionDetailsView(viewModel: viewModel.context))
)
}
}

View file

@ -0,0 +1,76 @@
//
// 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 UserSessionDetailsItemView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
let viewData: UserSessionDetailsSectionItemViewData
var body: some View {
HStack() {
Text(viewData.title)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxHeight: .infinity, alignment: .top)
Text(viewData.value)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.trailing)
}
.contextMenu {
Button {
UIPasteboard.general.string = viewData.value
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
.padding([.leading, .trailing], 20)
.padding([.top, .bottom], 12)
}
}
// MARK: - Previews
struct UserSessionDetailsItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
List {
UserSessionDetailsItemView(viewData: UserSessionDetailsSectionItemViewData(title: "Session name", value: "123"))
.theme(.light)
.preferredColorScheme(.light)
.listRowInsets(EdgeInsets())
}
.listStyle(.grouped)
List {
UserSessionDetailsItemView(viewData: UserSessionDetailsSectionItemViewData(title: "Session name", value: "123"))
.theme(.dark)
.preferredColorScheme(.dark)
.listRowInsets(EdgeInsets())
}
.listStyle(.grouped)
}
}
}

View file

@ -0,0 +1,40 @@
//
// 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 Foundation
enum UserSessionDetailsViewModelResult {
}
enum UserSessionDetailsViewAction {
}
struct UserSessionDetailsViewState: BindableState {
let sections: [UserSessionDetailsSectionViewData]
}
struct UserSessionDetailsSectionViewData: Identifiable {
let id = UUID()
let header: String
let footer: String?
let items: [UserSessionDetailsSectionItemViewData]
}
struct UserSessionDetailsSectionItemViewData: Identifiable {
let id = UUID()
let title: String
let value: String
}

View file

@ -0,0 +1,71 @@
//
// 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 UserSessionDetailsView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: UserSessionDetailsViewModel.Context
var body: some View {
List {
ForEach(viewModel.viewState.sections) { section in
SwiftUI.Section {
ForEach(section.items) { item in
UserSessionDetailsItemView(viewData: item)
.listRowInsets(EdgeInsets())
}
} header: {
Text(section.header)
.foregroundColor(theme.colors.secondaryContent)
.font(theme.fonts.footnote)
.padding([.leading, .trailing], 20)
.padding([.top, .bottom], 8)
} footer: {
if let footer = section.footer {
Text(footer)
.foregroundColor(theme.colors.secondaryContent)
.font(theme.fonts.footnote)
.padding([.leading, .trailing], 20)
.padding(.top, 8)
.padding(.bottom, 32)
}
}
.listRowInsets(EdgeInsets())
}
}
.listStyle(.grouped)
.navigationBarTitle("Session details", displayMode: .inline)
}
}
// MARK: - Previews
struct UserSessionDetails_Previews: PreviewProvider {
static let stateRenderer = MockUserSessionDetailsScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark)
}
}

View file

@ -0,0 +1,72 @@
//
// 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 Foundation
typealias UserSessionDetailsViewModelType = StateStoreViewModel<UserSessionDetailsViewState,
Never,
UserSessionDetailsViewAction>
class UserSessionDetailsViewModel: UserSessionDetailsViewModelType {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((UserSessionDetailsViewModelResult) -> Void)?
// MARK: - Setup
init(userSessionInfo: UserSessionInfo) {
var sections = [UserSessionDetailsSectionViewData]()
var sessionItems = [UserSessionDetailsSectionItemViewData]()
if let sessionName = userSessionInfo.sessionName {
sessionItems.append(UserSessionDetailsSectionItemViewData(title: "Session name",
value: sessionName))
}
sessionItems.append(UserSessionDetailsSectionItemViewData(title: "Session ID",
value: userSessionInfo.sessionId))
sections.append(UserSessionDetailsSectionViewData(header: "SESSION",
footer: "Copy any data by tapping on it and holding it down.",
items: sessionItems))
var deviceSectionItems = [UserSessionDetailsSectionItemViewData]()
if let lastSeenIP = userSessionInfo.lastSeenIP {
deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: "IP address",
value: lastSeenIP))
}
if deviceSectionItems.count > 0 {
sections.append(UserSessionDetailsSectionViewData(header: "DEVICE",
footer: nil,
items: deviceSectionItems))
}
let initialViewState = UserSessionDetailsViewState(sections: sections)
super.init(initialViewState: initialViewState)
}
// MARK: - Public
override func process(viewAction: UserSessionDetailsViewAction) {
}
// MARK: - Private
}

View file

@ -0,0 +1,89 @@
// File created from FlowTemplate
// $ createRootCoordinator.sh Folder UserSessionFlow UserSessionOverview
/*
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 CommonKit
import UIKit
protocol UserSessionFlowCoordinatorDelegate: AnyObject {
// func userSessionFlowCoordinatorDidComplete(_ coordinator: UserSessionFlowCoordinatorProtocol)
//
// /// Called when the view has been dismissed by gesture when presented modally (not in full screen).
// func userSessionFlowCoordinatorDidDismissInteractively(_ coordinator: UserSessionFlowCoordinatorProtocol)
}
struct UserSessionFlowCoordinatorParameters {
let session: MXSession
let navigationRouter: NavigationRouterType
let userSessionInfo: UserSessionInfo
}
final class UserSessionFlowCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: UserSessionFlowCoordinatorParameters
private let userSessionDetailsViewHostingController: UIViewController
private var userSessionDetailsViewModel: UserSessionDetailsViewModel
private var navigationRouter: NavigationRouterType {
return self.parameters.navigationRouter
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
weak var delegate: UserSessionFlowCoordinatorDelegate?
// MARK: - Setup
init(parameters: UserSessionFlowCoordinatorParameters) {
self.parameters = parameters
// todo: builder
let viewModel = UserSessionDetailsViewModel(userSessionInfo: parameters.userSessionInfo)
let view = UserSessionDetailsView(viewModel: viewModel.context)
userSessionDetailsViewModel = viewModel
userSessionDetailsViewHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
// TODO: change to showUserSessionOverview()
showUserSessionDetails()
}
private func showUserSessionOverview() {
// TODO: PSG-690
}
private func showUserSessionDetails() {
self.navigationRouter.push(self.userSessionDetailsViewHostingController, animated: true, popCompletion: nil)
}
func toPresentable() -> UIViewController {
return self.navigationRouter.toPresentable()
}
// MARK: - Private
}

View file

@ -52,6 +52,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
let rootCoordinatorParameters = UserSessionsOverviewCoordinatorParameters(session: self.parameters.session)
let rootCoordinator = UserSessionsOverviewCoordinator(parameters: rootCoordinatorParameters)
rootCoordinator.delegate = self
rootCoordinator.start()
@ -74,3 +75,14 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
return self.navigationRouter.toPresentable()
}
}
extension UserSessionsFlowCoordinator: UserSessionsOverviewCoordinatorDelegate {
func showUserSessionOverview(session: UserSessionInfo) {
let parameters = UserSessionFlowCoordinatorParameters(session: parameters.session,
navigationRouter: navigationRouter,
userSessionInfo: session)
let coordinator = UserSessionFlowCoordinator(parameters: parameters)
coordinator.start()
self.add(childCoordinator: coordinator)
}
}

View file

@ -21,6 +21,10 @@ struct UserSessionsOverviewCoordinatorParameters {
let session: MXSession
}
protocol UserSessionsOverviewCoordinatorDelegate: AnyObject {
func showUserSessionOverview(session: UserSessionInfo)
}
final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
// MARK: - Properties
@ -30,6 +34,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
private let parameters: UserSessionsOverviewCoordinatorParameters
private let userSessionsOverviewHostingController: UIViewController
private var userSessionsOverviewViewModel: UserSessionsOverviewViewModelProtocol
private let service: UserSessionsOverviewService
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
@ -40,11 +45,15 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
weak var delegate: UserSessionsOverviewCoordinatorDelegate?
// MARK: - Setup
init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: UserSessionsOverviewService(mxSession: parameters.session))
let service = UserSessionsOverviewService(mxSession: parameters.session)
self.service = service
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
let view = UserSessionsOverview(viewModel: viewModel.context)
userSessionsOverviewViewModel = viewModel
@ -117,7 +126,10 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
}
private func showUserSessionDetails(sessionId: String) {
// TODO
guard let sessionInfo = service.getOtherSession(sessionId: sessionId) else {
return
}
delegate?.showUserSessionOverview(session: sessionInfo)
}
private func showAllOtherSessions() {

View file

@ -47,14 +47,18 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
self.mxSession.matrixRestClient.devices { response in
switch response {
case .success(let devices):
let overviewData = self.userSessionsOverviewData(from: devices)
completion(.success(overviewData))
self.lastOverviewData = self.userSessionsOverviewData(from: devices)
completion(.success(self.lastOverviewData))
case .failure(let error):
completion(.failure(error))
}
}
}
func getOtherSession(sessionId: String) -> UserSessionInfo? {
lastOverviewData.otherSessionsInfo.first(where: {$0.sessionId == sessionId})
}
// MARK: - Private
private func setupInitialOverviewData() {