Adopt StateStoreViewModelV2 in the remaining settings screens. (#4158)

* Move the AuthenticationStartScreen into the Authentication directory.

* Commit the updated Sentry license.

No idea why they dropped the 2024 🤷‍♂️

* Use StateStoreViewModelV2 in BugReportScreen.

* Use StateStoreViewModelV2 in UserDetailsEditScreen.

* Use StateStoreViewModelV2 in NotificationSettingsScreen.

* Use StateStoreViewModelV2 in NotificationSettingsEditScreen.

* Use StateStoreViewModelV2 in LegalInformationScreen.

* Use StateStoreViewModelV2 in LogViewerScreen.

* Use StateStoreViewModelV2 in AnalyticsSettingsScreen.

* Rename AdvancedSettingsScreen directory.

* Use StateStoreViewModelV2 in EncryptionResetScreen.

* Use StateStoreViewModelV2 in EncryptionResetPasswordScreen.

* Use StateStoreViewModelV2 in SecureBackup…Screens.

* Use StateStoreViewModelV2 in LoginScreen.

Seems this one was ignored waiting on the fulfillment transitionValues implementation.

* Use StateStoreViewModelV2 in DeactivateAccountScreen.

* Move DeactivateAccountScreen into the Settings directory.
This commit is contained in:
Doug
2025-05-30 12:24:56 +01:00
committed by GitHub
parent 565ca3ab0b
commit 458c6aaa57
54 changed files with 238 additions and 294 deletions
@@ -0,0 +1,65 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct AuthenticationStartScreenParameters {
let authenticationService: AuthenticationServiceProtocol
let provisioningParameters: AccountProvisioningParameters?
let isBugReportServiceEnabled: Bool
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
private var viewModel: AuthenticationStartScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AuthenticationStartScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<AuthenticationStartScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: AuthenticationStartScreenParameters) {
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
provisioningParameters: parameters.provisioningParameters,
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
}
// MARK: - Public
func start() {
viewModel.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .loginWithQR:
actionsSubject.send(.loginWithQR)
case .login:
actionsSubject.send(.login)
case .register:
actionsSubject.send(.register)
case .reportProblem:
actionsSubject.send(.reportProblem)
case .loginDirectlyWithOIDC(let data, let window):
actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window))
case .loginDirectlyWithPassword(let loginHint):
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(AuthenticationStartScreen(context: viewModel.context))
}
}
@@ -0,0 +1,70 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
// MARK: - Coordinator
enum AuthenticationStartScreenCoordinatorAction {
case loginWithQR
case login
case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
}
enum AuthenticationStartScreenViewModelAction: Equatable {
case loginWithQR
case login
case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?)
}
struct AuthenticationStartScreenViewState: BindableState {
/// The presentation anchor used for OIDC authentication.
var window: UIWindow?
let serverName: String?
let showCreateAccountButton: Bool
let showQRCodeLoginButton: Bool
let showReportProblemButton: Bool
var bindings = AuthenticationStartScreenViewStateBindings()
var loginButtonTitle: String {
if let serverName {
L10n.screenOnboardingSignInTo(serverName)
} else if showQRCodeLoginButton {
L10n.screenOnboardingSignInManually
} else {
L10n.actionContinue
}
}
}
struct AuthenticationStartScreenViewStateBindings {
var alertInfo: AlertInfo<AuthenticationStartScreenAlertType>?
}
enum AuthenticationStartScreenAlertType {
case genericError
}
enum AuthenticationStartScreenViewAction {
/// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow)
case loginWithQR
case login
case register
case reportProblem
}
@@ -0,0 +1,131 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction>
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
private let authenticationService: AuthenticationServiceProtocol
private let provisioningParameters: AccountProvisioningParameters?
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<AuthenticationStartScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(authenticationService: AuthenticationServiceProtocol,
provisioningParameters: AccountProvisioningParameters?,
isBugReportServiceEnabled: Bool,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.provisioningParameters = provisioningParameters
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
let initialViewState = if !appSettings.allowOtherAccountProviders {
// We don't show the create account button when custom providers are disallowed.
// The assumption here being that if you're running a custom app, your users will already be created.
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
showCreateAccountButton: false,
showQRCodeLoginButton: isQRCodeScanningSupported,
showReportProblemButton: isBugReportServiceEnabled)
} else if let provisioningParameters {
// We only show the "Sign in to …" button when using a provisioning link.
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
showCreateAccountButton: false,
showQRCodeLoginButton: false,
showReportProblemButton: isBugReportServiceEnabled)
} else {
// The default configuration.
AuthenticationStartScreenViewState(serverName: nil,
showCreateAccountButton: appSettings.showCreateAccountButton,
showQRCodeLoginButton: isQRCodeScanningSupported,
showReportProblemButton: isBugReportServiceEnabled)
}
super.init(initialViewState: initialViewState)
}
override func process(viewAction: AuthenticationStartScreenViewAction) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
state.window = window
case .loginWithQR:
actionsSubject.send(.loginWithQR)
case .login:
Task { await login() }
case .register:
actionsSubject.send(.register)
case .reportProblem:
actionsSubject.send(.reportProblem)
}
}
// MARK: - Private
private func login() async {
if let serverName = state.serverName {
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
} else {
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
}
}
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil) async {
startLoading()
defer { stopLoading() }
guard case .success = await authenticationService.configure(for: accountProvider, flow: .login) else {
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
displayError()
return
}
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
return
}
guard let window = state.window else {
displayError()
return
}
switch await authenticationService.urlForOIDCLogin(loginHint: loginHint) {
case .success(let oidcData):
actionsSubject.send(.loginDirectlyWithOIDC(data: oidcData, window: window))
case .failure:
displayError()
}
}
private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading"
private func startLoading() {
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
}
private func displayError() {
state.bindings.alertInfo = AlertInfo(id: .genericError)
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
@MainActor
protocol AuthenticationStartScreenViewModelProtocol {
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> { get }
var context: AuthenticationStartScreenViewModelType.Context { get }
}
@@ -0,0 +1,89 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
/// The app's logo styled to fit on various launch pages.
struct AuthenticationStartLogo: View {
@Environment(\.colorScheme) private var colorScheme
/// Set to `true` when using on top of `Asset.Images.launchBackground`
let isOnGradient: Bool
/// Extra padding needed to avoid cropping the shadows.
private let extra: CGFloat = 64
/// The shape that the logo is composed on top of.
private let outerShape = RoundedRectangle(cornerRadius: 44)
private let outerShapeShadowColor = Color(red: 0.11, green: 0.11, blue: 0.13)
private var isLight: Bool { colorScheme == .light }
var body: some View {
Image(asset: Asset.Images.appLogo)
.background {
Circle()
.inset(by: 1)
.shadow(color: .black.opacity(!isLight && isOnGradient ? 0.3 : 0.4),
radius: 12.57143,
y: 6.28571)
Circle()
.inset(by: 1)
.shadow(color: .black.opacity(0.5),
radius: 12.57143,
y: 6.28571)
.blendMode(.overlay)
}
.padding(24)
.background {
Color.white
.opacity(isLight ? 0.23 : isOnGradient ? 0.05 : 0.13)
}
.clipShape(outerShape)
.overlay {
outerShape
.inset(by: 0.25)
.stroke(.white.opacity(isLight ? 1 : isOnGradient ? 0.9 : 0.25), lineWidth: 0.5)
.blendMode(isLight ? .normal : .overlay)
}
.padding(extra)
.background {
ZStack {
if !isLight, isOnGradient {
outerShape
.inset(by: 1)
.padding(extra)
.shadow(color: .black.opacity(0.5),
radius: 32.91666,
y: 1.05333)
} else {
outerShape
.inset(by: 1)
.padding(extra)
.shadow(color: outerShapeShadowColor.opacity(isLight ? 0.23 : 0.08),
radius: 16,
y: 8)
outerShape
.inset(by: 1)
.padding(extra)
.shadow(color: outerShapeShadowColor.opacity(0.5),
radius: 16,
y: 8)
.blendMode(.overlay)
}
}
.mask {
outerShape
.inset(by: -extra / 2)
.stroke(lineWidth: extra)
.padding(extra)
}
}
.padding(-extra)
.accessibilityHidden(true)
}
}
@@ -0,0 +1,145 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
/// The screen shown at the beginning of the onboarding flow.
struct AuthenticationStartScreen: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
let context: AuthenticationStartScreenViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
content
.frame(width: geometry.size.width)
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.hidden)
buttons
.frame(width: geometry.size.width)
.padding(.bottom, UIConstants.actionButtonBottomPadding)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
.padding(.top, 8)
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
}
.frame(maxHeight: .infinity)
.safeAreaInset(edge: .bottom) {
if context.viewState.showReportProblemButton {
Button {
context.send(viewAction: .reportProblem)
} label: {
Text(L10n.commonReportAProblem)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.padding(.bottom)
}
.frame(width: geometry.size.width)
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.reportAProblem)
}
}
}
.navigationBarHidden(true)
.background {
AuthenticationStartScreenBackgroundImage()
}
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
}
var content: some View {
VStack(spacing: 0) {
Spacer()
if verticalSizeClass == .regular {
Spacer()
AuthenticationStartLogo(isOnGradient: true)
}
Spacer()
VStack(spacing: 8) {
Text(L10n.screenOnboardingWelcomeTitle)
.font(.compound.headingLGBold)
.foregroundColor(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenOnboardingWelcomeMessage(InfoPlistReader.main.productionAppName))
.font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
.multilineTextAlignment(.center)
}
.padding()
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.bottom)
.padding(.horizontal, 16)
.readableFrame()
}
/// The main action buttons.
var buttons: some View {
VStack(spacing: 16) {
if context.viewState.showQRCodeLoginButton {
Button { context.send(viewAction: .loginWithQR) } label: {
Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signInWithQr)
}
Button { context.send(viewAction: .login) } label: {
Text(context.viewState.loginButtonTitle)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signIn)
if context.viewState.showCreateAccountButton {
Button { context.send(viewAction: .register) } label: {
Text(L10n.screenCreateAccountTitle)
}
.buttonStyle(.compound(.tertiary))
}
}
.padding(.horizontal, verticalSizeClass == .compact ? 128 : 24)
.readableFrame()
}
}
// MARK: - Previews
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let bugReportDisabledViewModel = makeViewModel(isBugReportServiceEnabled: false)
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
static var previews: some View {
AuthenticationStartScreen(context: viewModel.context)
.previewDisplayName("Default")
AuthenticationStartScreen(context: bugReportDisabledViewModel.context)
.previewDisplayName("Bug report disabled")
AuthenticationStartScreen(context: provisionedViewModel.context)
.previewDisplayName("Provisioned")
}
static func makeViewModel(isBugReportServiceEnabled: Bool = true, provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
isBugReportServiceEnabled: isBugReportServiceEnabled,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -0,0 +1,19 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
/// The background gradient shown on the launch, splash and onboarding screens.
struct AuthenticationStartScreenBackgroundImage: View {
var body: some View {
Image(asset: Asset.Images.launchBackground)
.resizable()
.scaledToFill()
.ignoresSafeArea()
.accessibilityHidden(true)
}
}