mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-04 06:58:20 +02:00
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:
+98
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user