Re-organise SettingsScreen to have distinct Account/App settings. (#6288)

* Re-organise SettingsScreen to have distinct Account/App settings.

* Remove the AdvancedSettingsScreen.
This commit is contained in:
Doug
2026-10-07 16:51:28 +01:00
committed by GitHub
parent dafda65cbe
commit 3b40aadc98
27 changed files with 321 additions and 687 deletions
@@ -85,44 +85,52 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
actionsSubject.send(.dismiss)
case .logout:
actionsSubject.send(.runLogoutFlow)
case .secureBackup:
startEncryptionSettingsFlow()
case .userDetails:
presentUserDetailsEditScreen()
case let .userStatusEmojiPicker(continuation):
presentEmojiPicker(emojiPickerContinuation: continuation)
case .addAccount:
break // Nothing to present until the add account flow exists.
case .linkNewDevice:
startLinkNewDeviceFlow()
case let .manageAccount(url):
presentAccountManagementURL(url)
case .analytics:
presentAnalyticsScreen()
case .appLock:
presentAppLockSetupFlow()
case .linkNewDevice:
startLinkNewDeviceFlow()
case .notifications:
presentNotificationSettings()
case .secureBackup:
startEncryptionSettingsFlow()
case .moderationAndSafety:
presentModerationAndSafetySettings()
case .bugReport:
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .push(navigationStackCoordinator),
userIndicatorController: flowParameters.userIndicatorController,
bugReportService: flowParameters.bugReportService,
userSession: flowParameters.userSession))
bugReportFlowCoordinator?.start()
case .about:
presentLegalInformationScreen()
case .blockedUsers:
presentBlockedUsersScreen()
case .notifications:
presentNotificationSettings()
case .advancedSettings:
presentAdvancedSettings()
case .labs:
presentLabs()
case .developerOptions:
presentDeveloperOptions()
case .logout:
actionsSubject.send(.runLogoutFlow)
case .deactivateAccount:
presentDeactivateAccount()
case .mediaUploadQuality:
presentMediaUploadQualitySettings()
case .appLock:
presentAppLockSetupFlow()
case .locationSharing:
presentLocationSharingSettings()
case .analytics:
presentAnalyticsScreen()
case .labs:
presentLabs()
case .about:
presentLegalInformationScreen()
case .developerOptions:
presentDeveloperOptions()
}
}
.store(in: &cancellables)
@@ -273,11 +281,34 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.push(coordinator)
}
private func presentAdvancedSettings() {
let coordinator = AdvancedSettingsScreenCoordinator(parameters: .init(userSettings: flowParameters.userSettings,
analytics: flowParameters.analytics,
clientProxy: flowParameters.userSession.clientProxy,
userIndicatorController: flowParameters.userIndicatorController))
private func presentModerationAndSafetySettings() {
let parameters = ModerationAndSafetySettingsScreenCoordinatorParameters(userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = ModerationAndSafetySettingsScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .blockedUsers:
presentBlockedUsersScreen()
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
private func presentMediaUploadQualitySettings() {
let parameters = MediaUploadQualitySettingsScreenCoordinatorParameters(userSettings: flowParameters.userSettings,
analytics: flowParameters.analytics)
let coordinator = MediaUploadQualitySettingsScreenCoordinator(parameters: parameters)
navigationStackCoordinator.push(coordinator)
}
private func presentLocationSharingSettings() {
let parameters = LocationSharingSettingsScreenCoordinatorParameters(userSettings: flowParameters.userSettings)
let coordinator = LocationSharingSettingsScreenCoordinator(parameters: parameters)
navigationStackCoordinator.push(coordinator)
}
@@ -11,7 +11,6 @@ enum TestablePreviewsDictionary {
static let dictionary: [String: any PreviewProvider.Type] = {
var dictionary: [String: any PreviewProvider.Type] = [
"ActiveCallTimelineItemView_Previews" : ActiveCallTimelineItemView_Previews.self,
"AdvancedSettingsScreen_Previews" : AdvancedSettingsScreen_Previews.self,
"AnalyticsPromptScreen_Previews" : AnalyticsPromptScreen_Previews.self,
"AnalyticsSettingsScreen_Previews" : AnalyticsSettingsScreen_Previews.self,
"AppLockScreen_Previews" : AppLockScreen_Previews.self,
@@ -1,32 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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 AdvancedSettingsScreenCoordinatorParameters {
let userSettings: UserSettings
let analytics: AnalyticsServiceProtocol
let clientProxy: ClientProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
final class AdvancedSettingsScreenCoordinator: CoordinatorProtocol {
private var viewModel: AdvancedSettingsScreenViewModelProtocol
init(parameters: AdvancedSettingsScreenCoordinatorParameters) {
viewModel = AdvancedSettingsScreenViewModel(advancedSettings: parameters.userSettings.app,
analytics: parameters.analytics,
clientProxy: parameters.clientProxy,
userIndicatorController: parameters.userIndicatorController)
}
func toPresentable() -> AnyView {
AnyView(AdvancedSettingsScreen(context: viewModel.context))
}
}
@@ -1,65 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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 Foundation
import UIKit
struct AdvancedSettingsScreenViewState: BindableState {
init(timelineMediaVisibility: TimelineMediaVisibility, hideInviteAvatars: Bool, isWaitingTimelineMediaVisibility: Bool = false, isWaitingHideInviteAvatars: Bool = false, bindings: AdvancedSettingsScreenViewStateBindings) {
self.timelineMediaVisibility = timelineMediaVisibility
self.hideInviteAvatars = hideInviteAvatars
self.isWaitingTimelineMediaVisibility = isWaitingTimelineMediaVisibility
self.isWaitingHideInviteAvatars = isWaitingHideInviteAvatars
self.bindings = bindings
let linkPlaceholder = "{link}"
var footerString = AttributedString(L10n.screenAdvancedSettingsLiveLocationSectionFooter(linkPlaceholder))
var linkString = AttributedString(L10n.screenAdvancedSettingsLiveLocationSectionFooterLink)
linkString.link = URL(string: UIApplication.openSettingsURLString)
linkString.bold()
footerString.replace(linkPlaceholder, with: linkString)
liveLocationUpdateFooterAttributedString = footerString
}
let liveLocationUpdateFooterAttributedString: AttributedString
var timelineMediaVisibility: TimelineMediaVisibility
var hideInviteAvatars: Bool
var isWaitingTimelineMediaVisibility: Bool
var isWaitingHideInviteAvatars: Bool
var bindings: AdvancedSettingsScreenViewStateBindings
}
@dynamicMemberLookup
struct AdvancedSettingsScreenViewStateBindings {
private let advancedSettings: AdvancedSettingsProtocol
init(advancedSettings: AdvancedSettingsProtocol) {
self.advancedSettings = advancedSettings
}
subscript<Setting>(dynamicMember keyPath: ReferenceWritableKeyPath<AdvancedSettingsProtocol, Setting>) -> Setting {
get { advancedSettings[keyPath: keyPath] }
set { advancedSettings[keyPath: keyPath] = newValue }
}
}
enum AdvancedSettingsScreenViewAction {
case optimizeMediaUploadsChanged
case updateTimelineMediaVisibility(TimelineMediaVisibility)
case updateHideInviteAvatars(Bool)
}
protocol AdvancedSettingsProtocol: AnyObject {
var viewSourceEnabled: Bool { get set }
var appAppearance: AppAppearance { get set }
var sharePresence: Bool { get set }
var optimizeMediaUploads: Bool { get set }
var liveLocationMinimumDistanceUpdate: Int { get set }
}
extension AppSettings: AdvancedSettingsProtocol { }
@@ -1,101 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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 AdvancedSettingsScreenViewModelType = StateStoreViewModelV2<AdvancedSettingsScreenViewState, AdvancedSettingsScreenViewAction>
class AdvancedSettingsScreenViewModel: AdvancedSettingsScreenViewModelType, AdvancedSettingsScreenViewModelProtocol {
private let analytics: AnalyticsServiceProtocol
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var timelineMediaVisibilityTask: Task<Void, Never>?
private var hideInviteAvatarsTask: Task<Void, Never>?
init(advancedSettings: AdvancedSettingsProtocol,
analytics: AnalyticsServiceProtocol,
clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.analytics = analytics
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
let state = AdvancedSettingsScreenViewState(timelineMediaVisibility: clientProxy.timelineMediaVisibilityPublisher.value,
hideInviteAvatars: clientProxy.hideInviteAvatarsPublisher.value,
bindings: .init(advancedSettings: advancedSettings))
super.init(initialViewState: state)
clientProxy.timelineMediaVisibilityPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.timelineMediaVisibility, on: self)
.store(in: &cancellables)
clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
}
override func process(viewAction: AdvancedSettingsScreenViewAction) {
switch viewAction {
case .optimizeMediaUploadsChanged:
// Note: Using a view action here as sinking the UserSettings publisher tracks the initial value.
analytics.trackInteraction(name: state.bindings.optimizeMediaUploads ? .MobileSettingsOptimizeMediaUploadsEnabled : .MobileSettingsOptimizeMediaUploadsDisabled)
case let .updateHideInviteAvatars(value):
hideInviteAvatarsTask = Task { [weak self] in await self?.updateHideInviteAvatars(value) }
case let .updateTimelineMediaVisibility(value):
timelineMediaVisibilityTask = Task { [weak self] in await self?.updateTimelineMediaVisibility(value) }
}
}
private func updateTimelineMediaVisibility(_ value: TimelineMediaVisibility) async {
defer {
timelineMediaVisibilityTask = nil
state.isWaitingTimelineMediaVisibility = false
}
let previousState = state.timelineMediaVisibility
state.isWaitingTimelineMediaVisibility = true
state.timelineMediaVisibility = value
// If the other value is updating wait also for it to finish
await hideInviteAvatarsTask?.value
switch await clientProxy.setTimelineMediaVisibility(value) {
case .success:
break
case .failure:
state.timelineMediaVisibility = previousState
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func updateHideInviteAvatars(_ value: Bool) async {
defer {
hideInviteAvatarsTask = nil
state.isWaitingHideInviteAvatars = false
}
let previousState = state.hideInviteAvatars
state.isWaitingHideInviteAvatars = true
state.hideInviteAvatars = value
// If the other value is updating wait also for it to finish
await timelineMediaVisibilityTask?.value
switch await clientProxy.setHideInviteAvatars(value) {
case .success:
break
case .failure:
state.hideInviteAvatars = previousState
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
@@ -1,13 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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
protocol AdvancedSettingsScreenViewModelProtocol {
var context: AdvancedSettingsScreenViewModelType.Context { get }
}
@@ -1,181 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 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
struct AdvancedSettingsScreen: View {
static let measurementFormatter = {
let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
formatter.unitStyle = .short
return formatter
}()
@Bindable var context: AdvancedSettingsScreenViewModel.Context
var body: some View {
Form {
Section {
ListRow(label: .plain(title: L10n.commonAppearance),
kind: .picker(selection: $context.appAppearance,
items: AppAppearance.allCases.map { (title: $0.name, tag: $0) }))
ListRow(label: .plain(title: L10n.actionViewSource,
description: L10n.screenAdvancedSettingsViewSourceDescription),
kind: .toggle($context.viewSourceEnabled))
ListRow(label: .plain(title: L10n.screenAdvancedSettingsSharePresence,
description: L10n.screenAdvancedSettingsSharePresenceDescription),
kind: .toggle($context.sharePresence))
ListRow(label: .plain(title: L10n.screenAdvancedSettingsMediaCompressionTitle,
description: L10n.screenAdvancedSettingsMediaCompressionDescription),
kind: .toggle($context.optimizeMediaUploads))
.onChange(of: context.optimizeMediaUploads) {
context.send(viewAction: .optimizeMediaUploadsChanged)
}
}
moderationAndSafetySection
timelineMediaSection
liveLocationSection
}
.compoundList()
.navigationTitle(L10n.commonAdvancedSettings)
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var moderationAndSafetySection: some View {
let binding = Binding(get: {
context.viewState.hideInviteAvatars
}, set: { newValue in
context.send(viewAction: .updateHideInviteAvatars(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsHideInviteAvatarsToggleTitle),
details: context.viewState.isWaitingHideInviteAvatars ? .isWaiting(true) : nil,
kind: .toggle(binding))
.disabled(context.viewState.isWaitingHideInviteAvatars)
} header: {
Text(L10n.screenAdvancedSettingsModerationAndSafetySectionTitle)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var timelineMediaSection: some View {
let binding = Binding(get: {
context.viewState.timelineMediaVisibility
}, set: { newValue in
context.send(viewAction: .updateTimelineMediaVisibility(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsShowMediaTimelineTitle),
details: .isWaiting(context.viewState.isWaitingTimelineMediaVisibility),
kind: .inlinePicker(selection: binding,
items: TimelineMediaVisibility.items))
.disabled(context.viewState.isWaitingTimelineMediaVisibility)
} header: {
Text(L10n.screenAdvancedSettingsShowMediaTimelineTitle)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenAdvancedSettingsShowMediaTimelineSubtitle)
.compoundListSectionFooter()
}
}
@ViewBuilder
private var liveLocationSection: some View {
let binding = Binding(get: {
Double(context.liveLocationMinimumDistanceUpdate)
}, set: { newValue in
context.liveLocationMinimumDistanceUpdate = Int(newValue)
})
Section {
ListRow(kind: .custom {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.screenAdvancedSettingsLiveLocationUpdateDistance(context.liveLocationMinimumDistanceUpdate))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
// The internal hidden label of the slider will read voice over
.accessibilityHidden(true)
Slider(value: binding, in: 1...100) {
Text(L10n.screenAdvancedSettingsLiveLocationUpdateDistance(context.liveLocationMinimumDistanceUpdate))
} minimumValueLabel: {
Text(Self.measurementFormatter.string(from: .init(value: 1,
unit: UnitLength.meters)))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
.padding(.trailing, 15)
} maximumValueLabel: {
Text(Self.measurementFormatter.string(from: .init(value: 100,
unit: UnitLength.meters)))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
.padding(.leading, 15)
}
.tint(.compound.iconAccentPrimary)
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
})
} header: {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.screenAdvancedSettingsLiveLocationSectionTitle)
.compoundListSectionHeader()
Text(L10n.screenAdvancedSettingsLiveLocationSectionDescription)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
} footer: {
Text(context.viewState.liveLocationUpdateFooterAttributedString)
.compoundListSectionFooter()
}
}
}
private extension AppAppearance {
var name: String {
switch self {
case .system:
L10n.themeSystem
case .light:
L10n.themeLight
case .dark:
L10n.themeDark
}
}
}
// MARK: - Previews
struct AdvancedSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = AdvancedSettingsScreenViewModel(advancedSettings: AppSettings.volatile(),
analytics: AnalyticsServiceMock(.init()),
clientProxy: ClientProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
ElementNavigationStack {
AdvancedSettingsScreen(context: viewModel.context)
}
}
}
private extension TimelineMediaVisibility {
static var items: [(title: String, tag: TimelineMediaVisibility)] {
[(title: L10n.screenAdvancedSettingsShowMediaTimelineAlwaysHide, tag: .never),
(title: L10n.screenAdvancedSettingsShowMediaTimelinePrivateRooms, tag: .privateOnly),
(title: L10n.screenAdvancedSettingsShowMediaTimelineAlwaysShow, tag: .always)]
}
}
@@ -13,12 +13,34 @@ struct ModerationAndSafetySettingsScreenCoordinatorParameters {
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ModerationAndSafetySettingsScreenCoordinatorAction {
case blockedUsers
}
final class ModerationAndSafetySettingsScreenCoordinator: CoordinatorProtocol {
private var viewModel: ModerationAndSafetySettingsScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<ModerationAndSafetySettingsScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<ModerationAndSafetySettingsScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var cancellables = Set<AnyCancellable>()
init(parameters: ModerationAndSafetySettingsScreenCoordinatorParameters) {
viewModel = ModerationAndSafetySettingsScreenViewModel(userSession: parameters.userSession,
userIndicatorController: parameters.userIndicatorController)
viewModel.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .blockedUsers:
actionsSubject.send(.blockedUsers)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
@@ -19,23 +19,30 @@ struct SettingsScreenCoordinatorParameters {
enum SettingsScreenCoordinatorAction {
case dismiss
case logout
case secureBackup
case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case addAccount
case analytics
case appLock
case bugReport
case about
case blockedUsers
case linkNewDevice
case manageAccount(url: URL)
case linkNewDevice
case notifications
case advancedSettings
case labs
case developerOptions
case secureBackup
case moderationAndSafety
case bugReport
case logout
case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case developerOptions
}
final class SettingsScreenCoordinator: CoordinatorProtocol {
@@ -64,40 +71,48 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
switch action {
case .close:
actionsSubject.send(.dismiss)
case .userDetails:
actionsSubject.send(.userDetails)
case let .userStatusEmojiPicker(continuation):
actionsSubject.send(.userStatusEmojiPicker(continuation))
case .addAccount:
actionsSubject.send(.addAccount)
case .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case let .manageAccount(url):
actionsSubject.send(.manageAccount(url: url))
case .analytics:
actionsSubject.send(.analytics)
case .appLock:
actionsSubject.send(.appLock)
case .reportBug:
actionsSubject.send(.bugReport)
case .about:
actionsSubject.send(.about)
case .blockedUsers:
actionsSubject.send(.blockedUsers)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case .notifications:
actionsSubject.send(.notifications)
case .advancedSettings:
actionsSubject.send(.advancedSettings)
case .labs:
actionsSubject.send(.labs)
case .developerOptions:
actionsSubject.send(.developerOptions)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .moderationAndSafety:
actionsSubject.send(.moderationAndSafety)
case .reportBug:
actionsSubject.send(.bugReport)
case .logout:
actionsSubject.send(.logout)
case .deactivateAccount:
actionsSubject.send(.deactivateAccount)
case .mediaUploadQuality:
actionsSubject.send(.mediaUploadQuality)
case .appLock:
actionsSubject.send(.appLock)
case .locationSharing:
actionsSubject.send(.locationSharing)
case .analytics:
actionsSubject.send(.analytics)
case .labs:
actionsSubject.send(.labs)
case .about:
actionsSubject.send(.about)
case .developerOptions:
actionsSubject.send(.developerOptions)
}
}
.store(in: &cancellables)
@@ -10,23 +10,30 @@ import SwiftUI
enum SettingsScreenViewModelAction {
case close
case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case addAccount
case linkNewDevice
case manageAccount(url: URL)
case analytics
case appLock
case reportBug
case about
case blockedUsers
case secureBackup
case linkNewDevice
case notifications
case advancedSettings
case labs
case developerOptions
case secureBackup
case moderationAndSafety
case reportBug
case logout
case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case developerOptions
}
enum SettingsScreenSecuritySectionMode {
@@ -47,14 +54,13 @@ struct SettingsScreenViewState: BindableState {
var securitySectionMode = SettingsScreenSecuritySectionMode.none
var showSecuritySectionBadge = false
var showBlockedUsers = false
let showAnalyticsSettings: Bool
let isBugReportServiceEnabled: Bool
let navigationBarVisibility: Visibility
var bindings = SettingsScreenViewStateBindings()
var bindings: SettingsScreenViewStateBindings
var userStatusRowMode: SettingsScreenUserStatusRow.Mode {
if bindings.isShowingCustomStatusField {
@@ -68,6 +74,8 @@ struct SettingsScreenViewState: BindableState {
}
struct SettingsScreenViewStateBindings {
private let userSettings: UserSettings
var isPresentingStatusPicker = false
var customStatusEmoji: Character = "😄"
var isShowingCustomStatusField = false {
@@ -79,29 +87,45 @@ struct SettingsScreenViewStateBindings {
}
var isPresentingAccountDeactivationConfirmation = false
var appAppearance: AppAppearance {
get { userSettings.appAppearance }
set { userSettings.appAppearance = newValue }
}
init(userSettings: UserSettings) {
self.userSettings = userSettings
}
}
enum SettingsScreenViewAction {
case close
case userDetails
case userStatus(UserStatusAction)
case addAccount
case analytics
case appLock
case reportBug
case about
case blockedUsers
case secureBackup
case linkNewDevice
case manageAccount(url: URL)
case linkNewDevice
case notifications
case enableDeveloperOptions
case developerOptions
case advancedSettings
case labs
case secureBackup
case moderationAndSafety
case reportBug
case logout
case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case enableDeveloperOptions
case developerOptions
enum UserStatusAction {
/// Show status picker sheet to select a preset status.
case pickStatus
@@ -35,7 +35,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
showDeveloperOptions: userSettings.developerOptionsEnabled,
showAnalyticsSettings: userSettings.canPromptForAnalytics,
isBugReportServiceEnabled: isBugReportServiceEnabled,
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic),
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic,
bindings: .init(userSettings: userSettings)),
mediaProvider: userSession.mediaProvider)
userSettings.developerOptionsEnabledPublisher
@@ -77,18 +78,6 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
}
.store(in: &cancellables)
userSession.clientProxy.ignoredUsersPublisher
.receive(on: DispatchQueue.main)
.map {
guard let blockedUsers = $0 else {
return false
}
return !blockedUsers.isEmpty
}
.weakAssign(to: \.state.showBlockedUsers, on: self)
.store(in: &cancellables)
Task {
if case .success(true) = await userSession.clientProxy.isUserStatusSupported() {
state.showUserStatusInput = true
@@ -102,6 +91,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
switch viewAction {
case .close:
actionsSubject.send(.close)
case .userDetails:
actionsSubject.send(.userDetails)
case .userStatus(.pickStatus):
@@ -118,38 +108,45 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
case .userStatus(.cancel):
state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false
case .addAccount:
actionsSubject.send(.addAccount)
case .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case let .manageAccount(url):
actionsSubject.send(.manageAccount(url: url))
case .analytics:
actionsSubject.send(.analytics)
case .appLock:
actionsSubject.send(.appLock)
case .reportBug:
actionsSubject.send(.reportBug)
case .about:
actionsSubject.send(.about)
case .blockedUsers:
actionsSubject.send(.blockedUsers)
case .logout:
actionsSubject.send(.logout)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case .notifications:
actionsSubject.send(.notifications)
case .advancedSettings:
actionsSubject.send(.advancedSettings)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .moderationAndSafety:
actionsSubject.send(.moderationAndSafety)
case .reportBug:
actionsSubject.send(.reportBug)
case .logout:
actionsSubject.send(.logout)
case .deactivateAccount:
actionsSubject.send(.deactivateAccount)
case .mediaUploadQuality:
actionsSubject.send(.mediaUploadQuality)
case .appLock:
actionsSubject.send(.appLock)
case .locationSharing:
actionsSubject.send(.locationSharing)
case .analytics:
actionsSubject.send(.analytics)
case .labs:
actionsSubject.send(.labs)
case .about:
actionsSubject.send(.about)
case .enableDeveloperOptions:
userSettings.developerOptionsEnabled.toggle()
case .developerOptions:
actionsSubject.send(.developerOptions)
case .deactivateAccount:
actionsSubject.send(.deactivateAccount)
}
}
@@ -13,37 +13,19 @@ import SwiftUI
struct SettingsScreen: View {
@Bindable var context: SettingsScreenViewModel.Context
private var shouldHideManageAccountSection: Bool {
context.viewState.accountProfileURL == nil &&
!context.viewState.showBlockedUsers &&
!context.viewState.showLinkNewDeviceButton
}
var body: some View {
Form {
userSection
if context.viewState.showUserStatusInput {
userStatusSection
}
if context.viewState.showAddAccountButton {
addAccountSection
}
if !shouldHideManageAccountSection {
manageAccountSection
}
manageMyAppSection
generalSection
accountSettingsSection
signOutSection
if context.viewState.showDeveloperOptions {
developerOptionsSection
}
appSettingsSection
}
.compoundList()
.navigationTitle(L10n.commonSettings)
@@ -91,13 +73,11 @@ struct SettingsScreen: View {
.padding(.vertical, 8)
}
})
}
}
private var userStatusSection: some View {
Section {
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
context.send(viewAction: .userStatus(action))
if context.viewState.showUserStatusInput {
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
context.send(viewAction: .userStatus(action))
}
}
}
}
@@ -113,36 +93,7 @@ struct SettingsScreen: View {
}
}
private var manageMyAppSection: some View {
Section {
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
icon: \.notifications),
kind: .navigationLink {
context.send(viewAction: .notifications)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications)
ListRow(label: .default(title: L10n.commonScreenLock,
icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .appLock)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock)
switch context.viewState.securitySectionMode {
case .secureBackup:
ListRow(label: .default(title: L10n.commonEncryption,
icon: \.key),
details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil,
kind: .navigationLink { context.send(viewAction: .secureBackup) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup)
default:
EmptyView()
}
}
}
private var manageAccountSection: some View {
private var accountSettingsSection: some View {
Section {
if let url = context.viewState.accountProfileURL {
ListRow(label: .default(title: L10n.actionManageAccountAndDevices,
@@ -161,39 +112,30 @@ struct SettingsScreen: View {
})
}
if context.viewState.showBlockedUsers {
ListRow(label: .default(title: L10n.commonBlockedUsers,
icon: \.block),
kind: .navigationLink {
context.send(viewAction: .blockedUsers)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.blockedUsers)
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
icon: \.notifications),
kind: .navigationLink {
context.send(viewAction: .notifications)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications)
switch context.viewState.securitySectionMode {
case .secureBackup:
ListRow(label: .default(title: L10n.commonEncryption,
icon: \.key),
details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil,
kind: .navigationLink { context.send(viewAction: .secureBackup) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup)
default:
EmptyView()
}
}
}
private var generalSection: some View {
Section {
ListRow(label: .default(title: L10n.commonAdvancedSettings,
icon: \.settings),
kind: .navigationLink {
context.send(viewAction: .advancedSettings)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.advancedSettings)
ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs,
icon: \.labs),
ListRow(label: .default(title: L10n.commonModerationAndSafety,
icon: \.admin),
kind: .navigationLink {
context.send(viewAction: .labs)
context.send(viewAction: .moderationAndSafety)
})
ListRow(label: .default(title: L10n.commonAbout,
icon: \.info),
kind: .navigationLink {
context.send(viewAction: .about)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.about)
if context.viewState.isBugReportServiceEnabled {
ListRow(label: .default(title: L10n.commonReportAProblem,
icon: \.chatProblem),
@@ -202,15 +144,9 @@ struct SettingsScreen: View {
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.reportBug)
}
if context.viewState.showAnalyticsSettings {
ListRow(label: .default(title: L10n.commonAnalytics,
icon: \.chart),
kind: .navigationLink {
context.send(viewAction: .analytics)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics)
}
} header: {
Text(L10n.commonAccountSettings)
.compoundListSectionHeader()
}
}
@@ -232,21 +168,68 @@ struct SettingsScreen: View {
context.send(viewAction: .deactivateAccount)
})
}
} footer: {
if !context.viewState.showDeveloperOptions {
versionSection
}
}
}
private var developerOptionsSection: some View {
private var appSettingsSection: some View {
Section {
ListRow(label: .default(title: L10n.commonDeveloperOptions,
icon: \.code),
ListRow(label: .default(title: L10n.commonAppearance,
icon: \.darkMode),
kind: .picker(selection: $context.appAppearance,
items: AppAppearance.allCases.map { (title: $0.name, tag: $0) }))
ListRow(label: .default(title: L10n.commonMediaUploadQuality,
icon: \.image),
kind: .navigationLink {
context.send(viewAction: .developerOptions)
context.send(viewAction: .mediaUploadQuality)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions)
ListRow(label: .default(title: L10n.commonScreenLock,
icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .appLock)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock)
ListRow(label: .default(title: L10n.commonLocationSharing,
icon: \.locationPin),
kind: .navigationLink {
context.send(viewAction: .locationSharing)
})
if context.viewState.showAnalyticsSettings {
ListRow(label: .default(title: L10n.commonAnalytics,
icon: \.chart),
kind: .navigationLink {
context.send(viewAction: .analytics)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics)
}
ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs,
icon: \.labs),
kind: .navigationLink {
context.send(viewAction: .labs)
})
ListRow(label: .default(title: L10n.commonAbout,
icon: \.info),
kind: .navigationLink {
context.send(viewAction: .about)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.about)
if context.viewState.showDeveloperOptions {
ListRow(label: .default(title: L10n.commonDeveloperOptions,
icon: \.code),
kind: .navigationLink {
context.send(viewAction: .developerOptions)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions)
}
} header: {
Text(L10n.commonAppSettings)
.compoundListSectionHeader()
} footer: {
versionSection
}
@@ -291,6 +274,19 @@ struct SettingsScreen: View {
}
}
private extension AppAppearance {
var name: String {
switch self {
case .system:
L10n.themeSystem
case .light:
L10n.themeLight
case .dark:
L10n.themeDark
}
}
}
// MARK: - Previews
@available(iOS 26.0, *)
@@ -303,7 +299,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: viewModel.context)
}
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1200)
.frame(height: 1300)
.previewLayout(.sizeThatFits)
.previewDisplayName("Default")
@@ -311,7 +307,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: bugReportDisabledViewModel.context)
}
.snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1150)
.frame(height: 1250)
.previewLayout(.sizeThatFits)
.previewDisplayName("Bug report disabled")
}
@@ -323,6 +319,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
status: .mockFocussing))))
let userSettings = UserSettings.mock()
userSettings.multiAccountEnabled = true
userSettings.linkNewDeviceEnabled = true
return SettingsScreenViewModel(userSession: userSession,
userSettings: userSettings,
isBugReportServiceEnabled: isBugReportServiceEnabled,