diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 1a55df6d0f..dedbeb99e0 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2384,6 +2384,13 @@ To enable access, tap Settings> Location and select Always"; "device_name_mobile" = "%@ Mobile"; "device_name_unknown" = "Unknown client"; +"user_session_details_title" = "Session details"; +"user_session_details_session_section_header" = "SESSION"; +"user_session_details_device_section_header" = "DEVICE"; +"user_session_details_session_name" = "Session name"; +"user_session_details_session_id" = "Session ID"; +"user_session_details_session_section_footer" = "Copy any data by tapping on it and holding it down."; +"user_session_details_device_ip_address" = "IP address"; // MARK: - MatrixKit diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index bdad88cd7e..f4de1f40b6 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8467,6 +8467,34 @@ public class VectorL10n: NSObject { public static var userIdTitle: String { return VectorL10n.tr("Vector", "user_id_title") } + /// IP address + public static var userSessionDetailsDeviceIpAddress: String { + return VectorL10n.tr("Vector", "user_session_details_device_ip_address") + } + /// DEVICE + public static var userSessionDetailsDeviceSectionHeader: String { + return VectorL10n.tr("Vector", "user_session_details_device_section_header") + } + /// Session ID + public static var userSessionDetailsSessionId: String { + return VectorL10n.tr("Vector", "user_session_details_session_id") + } + /// Session name + public static var userSessionDetailsSessionName: String { + return VectorL10n.tr("Vector", "user_session_details_session_name") + } + /// Copy any data by tapping on it and holding it down. + public static var userSessionDetailsSessionSectionFooter: String { + return VectorL10n.tr("Vector", "user_session_details_session_section_footer") + } + /// SESSION + public static var userSessionDetailsSessionSectionHeader: String { + return VectorL10n.tr("Vector", "user_session_details_session_section_header") + } + /// Session details + public static var userSessionDetailsTitle: String { + return VectorL10n.tr("Vector", "user_session_details_title") + } /// %@ ยท Last activity %@ public static func userSessionItemDetails(_ p1: String, _ p2: String) -> String { return VectorL10n.tr("Vector", "user_session_item_details", p1, p2) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index e52ac8e298..a87521164e 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,6 +20,7 @@ import Foundation enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ MockUserSessionsOverviewScreenState.self, + MockUserSessionDetailsScreenState.self, MockLiveLocationLabPromotionScreenState.self, MockLiveLocationSharingViewerScreenState.self, MockAuthenticationLoginScreenState.self, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift new file mode 100644 index 0000000000..378a53c6c2 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Coordinator/UserSessionDetailsCoordinator.swift @@ -0,0 +1,71 @@ +// +// 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 UserSessionDetailsCoordinatorParameters { + let session: MXSession + let userSessionInfo: UserSessionInfo +} + +final class UserSessionDetailsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: UserSessionDetailsCoordinatorParameters + private let userSessionDetailsHostingController: UIViewController + private var userSessionDetailsViewModel: UserSessionDetailsViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSessionDetailsViewModelResult) -> Void)? + + // MARK: - Setup + + init(parameters: UserSessionDetailsCoordinatorParameters) { + self.parameters = parameters + + let viewModel = UserSessionDetailsViewModel(userSessionInfo: parameters.userSessionInfo) + let view = UserSessionDetails(viewModel: viewModel.context) + userSessionDetailsViewModel = viewModel + userSessionDetailsHostingController = VectorHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionDetailsHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[UserSessionDetailsCoordinator] did start.") + userSessionDetailsViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[UserSessionDetailsCoordinator] UserSessionDetailsViewModel did complete with result: \(result).") + self.completion?(result) + } + } + + func toPresentable() -> UIViewController { + return self.userSessionDetailsHostingController + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift new file mode 100644 index 0000000000..317b2d015f --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -0,0 +1,67 @@ +// +// 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 + +/// 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 { + UserSessionDetails.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(UserSessionDetails(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift new file mode 100644 index 0000000000..60c5308f48 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift @@ -0,0 +1,36 @@ +// +// 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 UserSessionDetailsUITests: MockScreenTestCase { + + func test_longPressDetailsCell_CopiesValueToClipboard() throws { + app.goToScreenWithIdentifier(MockUserSessionDetailsScreenState.allSections.title) + + UIPasteboard.general.string = "" + + let tables = app.tables + let sessionNameIosCell = tables.cells["Session name, iOS"] + sessionNameIosCell.press(forDuration: 0.5) + + app.buttons["Copy"].tap() + + let clipboard = try XCTUnwrap(UIPasteboard.general.string) + XCTAssertEqual(clipboard,"iOS") + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift new file mode 100644 index 0000000000..c5aa629b54 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift @@ -0,0 +1,112 @@ +// +// 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 + +@testable import RiotSwiftUI + +class UserSessionDetailsViewModelTests: XCTestCase { + + func test_whenSessionNameAndLastSeenIPNil_viewStateCorrect() { + let userSessionInfo = createUserSessionInfo(sessionId: "session", + sessionName: nil, + lastSeenIP: nil) + + var sessionItems = [UserSessionDetailsSectionItemViewData]() + sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) + + var sections = [UserSessionDetailsSectionViewData]() + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + footer: VectorL10n.userSessionDetailsSessionSectionFooter, + items: sessionItems)) + let expectedModel = UserSessionDetailsViewState(sections: sections) + let sut = UserSessionDetailsViewModel(userSessionInfo: userSessionInfo) + + XCTAssertEqual(sut.state, expectedModel) + } + + func test_whenSessionNameNotNilLastSeenIPNil_viewStateCorrect() { + let userSessionInfo = createUserSessionInfo(sessionId: "session", + sessionName: "session name", + lastSeenIP: nil) + + var sessionItems = [UserSessionDetailsSectionItemViewData]() + sessionItems.append(sessionNameItem(sessionName: "session name")) + sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) + + var sections = [UserSessionDetailsSectionViewData]() + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + footer: VectorL10n.userSessionDetailsSessionSectionFooter, + items: sessionItems)) + + let expectedModel = UserSessionDetailsViewState(sections: sections) + let sut = UserSessionDetailsViewModel(userSessionInfo: userSessionInfo) + + XCTAssertEqual(sut.state, expectedModel) + } + + func test_whenUserSessionInfoContainsAllValues_viewStateCorrect() { + let userSessionInfo = createUserSessionInfo(sessionId: "session", + sessionName: "session name", + lastSeenIP: "0.0.0.0") + + var sessionItems = [UserSessionDetailsSectionItemViewData]() + sessionItems.append(sessionNameItem(sessionName: "session name")) + sessionItems.append(sessionIdItem(sessionId: userSessionInfo.sessionId)) + + var sections = [UserSessionDetailsSectionViewData]() + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + footer: VectorL10n.userSessionDetailsSessionSectionFooter, + items: sessionItems)) + + var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() + deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress, + value: "0.0.0.0")) + sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader, + footer: nil, + items: deviceSectionItems)) + + let expectedModel = UserSessionDetailsViewState(sections: sections) + let sut = UserSessionDetailsViewModel(userSessionInfo: userSessionInfo) + + XCTAssertEqual(sut.state, expectedModel) + } + + private func createUserSessionInfo(sessionId: String, + sessionName: String?, + deviceType: DeviceType = .mobile, + isVerified: Bool = false, + lastSeenIP: String?, + lastSeenTimestamp: TimeInterval = Date().timeIntervalSince1970) -> UserSessionInfo { + UserSessionInfo(sessionId: sessionId, + sessionName: sessionName, + deviceType: deviceType, + isVerified: isVerified, + lastSeenIP: lastSeenIP, + lastSeenTimestamp: lastSeenTimestamp) + + } + + private func sessionNameItem(sessionName: String) -> UserSessionDetailsSectionItemViewData { + UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName, + value: sessionName) + } + + private func sessionIdItem(sessionId: String) -> UserSessionDetailsSectionItemViewData { + UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, + value: sessionId) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsModels.swift new file mode 100644 index 0000000000..38d4cf31a1 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsModels.swift @@ -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 + +// MARK: - Coordinator + +// MARK: View model + +enum UserSessionDetailsViewModelResult { +} + +// MARK: View + +enum UserSessionDetailsViewAction { +} + +struct UserSessionDetailsViewState: BindableState, Equatable { + + 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 +} + +extension UserSessionDetailsSectionViewData: Equatable { + + static func == (lhs: UserSessionDetailsSectionViewData, rhs: UserSessionDetailsSectionViewData) -> Bool { + lhs.header == rhs.header && + lhs.footer == rhs.footer && + lhs.items == rhs.items + } +} + +extension UserSessionDetailsSectionItemViewData: Equatable { + + static func == (lhs: UserSessionDetailsSectionItemViewData, rhs: UserSessionDetailsSectionItemViewData) -> Bool { + lhs.title == rhs.title && + lhs.value == rhs.value + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift new file mode 100644 index 0000000000..c4b04bfecd --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift @@ -0,0 +1,85 @@ +// +// 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 + +typealias UserSessionDetailsViewModelType = StateStoreViewModel + +class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionDetailsViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((UserSessionDetailsViewModelResult) -> Void)? + + // MARK: - Setup + + init(userSessionInfo: UserSessionInfo) { + super.init(initialViewState: UserSessionDetailsViewState(sections: [])) + updateViewState(userSessionInfo: userSessionInfo) + } + + // MARK: - Public + + // MARK: - Private + + private func updateViewState(userSessionInfo: UserSessionInfo) { + var sections = [UserSessionDetailsSectionViewData]() + + sections.append(sessionSection(userSessionInfo: userSessionInfo)) + + if let deviceSection = deviceSection(userSessionInfo: userSessionInfo) { + sections.append(deviceSection) + } + + state = UserSessionDetailsViewState(sections: sections) + } + + private func sessionSection(userSessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData { + var sessionItems = [UserSessionDetailsSectionItemViewData]() + + if let sessionName = userSessionInfo.sessionName { + sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName, + value: sessionName)) + } + + sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, + value: userSessionInfo.sessionId)) + + return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader, + footer: VectorL10n.userSessionDetailsSessionSectionFooter, + items: sessionItems) + } + + private func deviceSection(userSessionInfo: UserSessionInfo) -> UserSessionDetailsSectionViewData? { + var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() + if let lastSeenIP = userSessionInfo.lastSeenIP { + deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress, + value: lastSeenIP)) + } + if deviceSectionItems.count > 0 { + return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader, + footer: nil, + items: deviceSectionItems) + } + return nil + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModelProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModelProtocol.swift new file mode 100644 index 0000000000..8222cc7708 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModelProtocol.swift @@ -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 UserSessionDetailsViewModelProtocol { + + var completion: ((UserSessionDetailsViewModelResult) -> Void)? { get set } + var context: UserSessionDetailsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift new file mode 100644 index 0000000000..3be8032712 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift @@ -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 SwiftUI + +struct UserSessionDetails: View { + + // MARK: - Properties + + // MARK: Private + + private enum LayoutConstants { + static let listItemHorizontalPadding: CGFloat = 20 + static let sectionVerticalPadding: CGFloat = 8 + } + + @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 + UserSessionDetailsItem(viewData: item, horizontalPadding: LayoutConstants.listItemHorizontalPadding) + .listRowInsets(EdgeInsets()) + } + } header: { + Text(section.header) + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.footnote) + .padding([.leading, .trailing], LayoutConstants.listItemHorizontalPadding) + .padding(.top, 32) + .padding(.bottom, LayoutConstants.sectionVerticalPadding) + } footer: { + if let footer = section.footer { + Text(footer) + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.footnote) + .padding([.leading, .trailing], LayoutConstants.listItemHorizontalPadding) + .padding(.top, LayoutConstants.sectionVerticalPadding) + } + } + .listRowInsets(EdgeInsets()) + } + } + .listStyle(.grouped) + .navigationBarTitle(VectorL10n.userSessionDetailsTitle) + } +} + +// 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) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift new file mode 100644 index 0000000000..870ac606a9 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift @@ -0,0 +1,90 @@ +// +// 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 UserSessionDetailsItem: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let viewData: UserSessionDetailsSectionItemViewData + let horizontalPadding: CGFloat + + init(viewData: UserSessionDetailsSectionItemViewData, horizontalPadding: CGFloat = 20) { + self.viewData = viewData + self.horizontalPadding = horizontalPadding + } + + 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(VectorL10n.copyButtonName, systemImage: "doc.on.doc") + } + } + .padding([.leading, .trailing], horizontalPadding) + .padding([.top, .bottom], 12) + } +} + +// MARK: - Previews + +struct UserSessionDetailsItem_Previews: PreviewProvider { + static var previews: some View { + Group { + List { + UserSessionDetailsItem(viewData: UserSessionDetailsSectionItemViewData(title: "Session name", + value: "Element Web: Firefox on macOS")) + .listRowInsets(EdgeInsets()) + UserSessionDetailsItem(viewData: UserSessionDetailsSectionItemViewData(title: "Session ID", + value: "76c95352559d-react-7c57680b93db-js-b64dbdce74b0")) + .listRowInsets(EdgeInsets()) + } + .preferredColorScheme(.light) + + .listStyle(.grouped) + List { + UserSessionDetailsItem(viewData: UserSessionDetailsSectionItemViewData(title: "Session name", + value: "Element Web: Firefox on macOS")) + .listRowInsets(EdgeInsets()) + UserSessionDetailsItem(viewData: UserSessionDetailsSectionItemViewData(title: "Session ID", + value: "76c95352559d-react-7c57680b93db-js-b64dbdce74b0")) + .listRowInsets(EdgeInsets()) + } + .preferredColorScheme(.dark) + .theme(.dark) + .listStyle(.grouped) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsFlow/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsFlow/Coordinator/UserSessionsFlowCoordinator.swift index 42151e7cff..b4835c8e23 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsFlow/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsFlow/Coordinator/UserSessionsFlowCoordinator.swift @@ -31,7 +31,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { private let navigationRouter: NavigationRouterType // MARK: Public - + // Must be used only internally var childCoordinators: [Coordinator] = [] var completion: (() -> Void)? @@ -44,19 +44,54 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { self.navigationRouter = parameters.router ?? NavigationRouter(navigationController: RiotNavigationController()) } + // MARK: - Private + + private func pushScreen(with coordinator: Coordinator & Presentable) { + add(childCoordinator: coordinator) + + self.navigationRouter.push(coordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: coordinator) + }) + + coordinator.start() + } + + private func createUserSessionsOverviewCoordinator() -> UserSessionsOverviewCoordinator { + let parameters = UserSessionsOverviewCoordinatorParameters(session: self.parameters.session) + + let coordinator = UserSessionsOverviewCoordinator(parameters: parameters) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case let .openSessionDetails(session: session): + self.openSessionDetails(session: session) + } + } + return coordinator + } + + private func openSessionDetails(session: UserSessionInfo) { + let coordinator = createUserSessionDetailsCoordinator(session: session) + pushScreen(with: coordinator) + } + + private func createUserSessionDetailsCoordinator(session: UserSessionInfo) -> UserSessionDetailsCoordinator { + let parameters = UserSessionDetailsCoordinatorParameters( + session: parameters.session, + userSessionInfo: session) + return UserSessionDetailsCoordinator(parameters: parameters) + } + // MARK: - Public func start() { MXLog.debug("[UserSessionsFlowCoordinator] did start.") - let rootCoordinatorParameters = UserSessionsOverviewCoordinatorParameters(session: self.parameters.session) - - let rootCoordinator = UserSessionsOverviewCoordinator(parameters: rootCoordinatorParameters) - + let rootCoordinator = createUserSessionsOverviewCoordinator() rootCoordinator.start() - + self.add(childCoordinator: rootCoordinator) - + if self.navigationRouter.modules.isEmpty == false { self.navigationRouter.push(rootCoordinator, animated: true, popCompletion: { [weak self] in self?.remove(childCoordinator: rootCoordinator) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 1494eb7c22..f8726ba5c0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -30,7 +30,8 @@ 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? @@ -38,13 +39,15 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: (() -> Void)? - + var completion: ((UserSessionsOverviewCoordinatorResult) -> Void)? + // 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 @@ -63,8 +66,6 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { guard let self = self else { return } MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") switch result { - case .cancel: - self.completion?() case .showAllUnverifiedSessions: self.showAllUnverifiedSessions() case .showAllInactiveSessions: @@ -117,7 +118,10 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { } private func showUserSessionDetails(sessionId: String) { - // TODO + guard let sessionInfo = service.getOtherSession(sessionId: sessionId) else { + return + } + completion?(.openSessionDetails(session: sessionInfo)) } private func showAllOtherSessions() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 59ccf1e02f..b9efa16e05 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -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() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index b5b408081e..7f30f0fe33 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -18,10 +18,13 @@ import Foundation // MARK: - Coordinator +enum UserSessionsOverviewCoordinatorResult { + case openSessionDetails(session: UserSessionInfo) +} + // MARK: View model enum UserSessionsOverviewViewModelResult { - case cancel case showAllUnverifiedSessions case showAllInactiveSessions case verifyCurrentSession diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index fbfb70c3f5..27601502f9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -75,7 +75,7 @@ struct UserSessionsOverview: View { viewModel.send(viewAction: .viewAppeared) } } - + private var otherSessionsSection: some View { SwiftUI.Section { diff --git a/changelog.d/6693.wip b/changelog.d/6693.wip new file mode 100644 index 0000000000..a50a166914 --- /dev/null +++ b/changelog.d/6693.wip @@ -0,0 +1 @@ +Device manager: User session details screen.