Device Manager: Rename Session (#6826)

* Publish the user sessions overview data.
* Add UserSessionName screen.
* Update logout action to match Figma more closely.
This commit is contained in:
Doug
2022-10-11 13:11:15 +01:00
committed by GitHub
parent 969c51db1e
commit efaf98fe6a
29 changed files with 765 additions and 123 deletions
@@ -0,0 +1,98 @@
//
// 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 SwiftUI
struct UserSessionNameCoordinatorParameters {
let session: MXSession
let sessionInfo: UserSessionInfo
}
final class UserSessionNameCoordinator: Coordinator, Presentable {
private let parameters: UserSessionNameCoordinatorParameters
private let userSessionNameHostingController: UIViewController
private var userSessionNameViewModel: UserSessionNameViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSessionNameCoordinatorResult) -> Void)?
init(parameters: UserSessionNameCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserSessionNameViewModel(sessionInfo: parameters.sessionInfo)
let view = UserSessionName(viewModel: viewModel.context)
userSessionNameViewModel = viewModel
userSessionNameHostingController = VectorHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: userSessionNameHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[UserSessionNameCoordinator] did start.")
userSessionNameViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[UserSessionNameCoordinator] UserSessionNameViewModel did complete with result: \(result).")
switch result {
case .updateName(let newName):
self.updateName(newName)
case .cancel:
self.completion?(.cancel)
}
}
}
func toPresentable() -> UIViewController { userSessionNameHostingController }
// MARK: - Private
/// Updates the name of the device, completing the screen's presentation if successful.
private func updateName(_ newName: String) {
startLoading()
parameters.session.matrixRestClient.setDeviceName(newName, forDevice: parameters.sessionInfo.id) { [weak self] response in
guard let self = self else { return }
guard response.isSuccess else {
MXLog.debug("[UserSessionNameCoordinator] Rename device (\(self.parameters.sessionInfo.id)) failed")
self.userSessionNameViewModel.processError(response.error as NSError?)
return
}
self.stopLoading()
self.completion?(.sessionNameUpdated)
}
}
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,51 @@
//
// 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 MockUserSessionNameScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case initialName
case empty
case changedName
/// The associated screen
var screenType: Any.Type {
UserSessionName.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: UserSessionNameViewModel
switch self {
case .initialName:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
case .empty:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
viewModel.state.bindings.sessionName = ""
case .changedName:
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
viewModel.state.bindings.sessionName = "iPhone SE"
}
return ([viewModel], AnyView(UserSessionName(viewModel: viewModel.context)))
}
}
@@ -0,0 +1,44 @@
//
// 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 RiotSwiftUI
import XCTest
class UserSessionNameUITests: MockScreenTestCase {
func testUserSessionNameInitialState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.initialName.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertFalse(doneButton.isEnabled)
}
func testUserSessionNameEmptyState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.empty.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertFalse(doneButton.isEnabled)
}
func testUserSessionNameChangedState() {
app.goToScreenWithIdentifier(MockUserSessionNameScreenState.changedName.title)
let doneButton = app.buttons[VectorL10n.done]
XCTAssertTrue(doneButton.exists)
XCTAssertTrue(doneButton.isEnabled)
}
}
@@ -0,0 +1,51 @@
//
// 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 UserSessionNameViewModelTests: XCTestCase {
var viewModel: UserSessionNameViewModelProtocol!
var context: UserSessionNameViewModelType.Context!
override func setUpWithError() throws {
viewModel = UserSessionNameViewModel(sessionInfo: .mockPhone)
context = viewModel.context
}
func testClearingName() {
// Given an unedited name.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
// When clearing the name.
context.sessionName = ""
// Then the done button should remain be disabled.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name is empty.")
}
func testChangingName() {
// Given an unedited name.
XCTAssertFalse(context.viewState.canUpdateName, "The done button should be disabled when the name hasn't changed.")
// When changing the name.
context.sessionName = "Alice's iPhone"
// Then the done button should be enabled.
XCTAssertTrue(context.viewState.canUpdateName, "The done button should be enabled when the name has been changed.")
}
}
@@ -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
enum UserSessionNameCoordinatorResult {
/// The user cancelled the rename operation.
case cancel
/// The user successfully updated the name of the session.
case sessionNameUpdated
}
// MARK: View model
enum UserSessionNameViewModelResult {
/// The user cancelled the rename operation.
case cancel
/// Update the session name to the supplied string.
case updateName(String)
}
// MARK: View
struct UserSessionNameViewState: BindableState {
var bindings: UserSessionNameBindings
/// The current name of the session before any updates are made.
let currentName: String
/// Whether or not to allow the user to update the session name.
var canUpdateName: Bool {
!bindings.sessionName.isEmpty && bindings.sessionName != currentName
}
}
struct UserSessionNameBindings {
/// The name input by the user.
var sessionName: String
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>?
}
enum UserSessionNameViewAction {
/// The user tapped the done button to update the session name.
case done
/// The user tapped the cancel button.
case cancel
/// The user tapped the Learn More link.
case learnMore
}
@@ -0,0 +1,45 @@
//
// 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 UserSessionNameViewModelType = StateStoreViewModel<UserSessionNameViewState, UserSessionNameViewAction>
class UserSessionNameViewModel: UserSessionNameViewModelType, UserSessionNameViewModelProtocol {
var completion: ((UserSessionNameViewModelResult) -> Void)?
init(sessionInfo: UserSessionInfo) {
super.init(initialViewState: UserSessionNameViewState(bindings: .init(sessionName: sessionInfo.name ?? ""),
currentName: sessionInfo.name ?? ""))
}
// MARK: - Public
override func process(viewAction: UserSessionNameViewAction) {
switch viewAction {
case .done:
completion?(.updateName(state.bindings.sessionName))
case .cancel:
completion?(.cancel)
case .learnMore:
#warning("To be implemented as part of PSG-714.")
}
}
func processError(_ error: NSError?) {
state.bindings.alertInfo = AlertInfo(error: error)
}
}
@@ -0,0 +1,26 @@
//
// 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 UserSessionNameViewModelProtocol {
var completion: ((UserSessionNameViewModelResult) -> Void)? { get set }
var context: UserSessionNameViewModelType.Context { get }
/// Update the view model to show that an error has occurred.
/// - Parameter error: The error to be displayed or `nil` to display a generic alert.
func processError(_ error: NSError?)
}
@@ -0,0 +1,78 @@
import SwiftUI
struct UserSessionName: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: UserSessionNameViewModel.Context
var body: some View {
List {
SwiftUI.Section {
TextField(VectorL10n.manageSessionName, text: $viewModel.sessionName)
.autocapitalization(.words)
.listRowBackground(theme.colors.background)
.introspectTextField {
$0.becomeFirstResponder()
$0.clearButtonMode = .whileEditing
}
} header: {
Text(VectorL10n.manageSessionName)
.foregroundColor(theme.colors.secondaryContent)
} footer: {
textFieldFooter
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.listStyle(.grouped)
.listBackgroundColor(theme.colors.system)
.navigationTitle(VectorL10n.manageSessionRename)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.accentColor(theme.colors.accent)
}
private var textFieldFooter: some View {
VStack(alignment: .leading, spacing: 16) {
Text(VectorL10n.manageSessionNameHint)
.foregroundColor(theme.colors.secondaryContent)
InlineTextButton(VectorL10n.manageSessionNameInfo("%@"),
tappableText: VectorL10n.manageSessionNameInfoLink) {
viewModel.send(viewAction: .learnMore)
}
.foregroundColor(theme.colors.secondaryContent)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(VectorL10n.done) {
viewModel.send(viewAction: .done)
}
.disabled(!viewModel.viewState.canUpdateName)
}
}
}
// MARK: - Previews
struct UserSessionName_Previews: PreviewProvider {
static let stateRenderer = MockUserSessionNameScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light)
.preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark)
.preferredColorScheme(.dark)
}
}