Files
element-x-ios/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift
T
Mauro 2d486ece33 Use iOS 26 Observation for Unit and Preview tests. (#5797)
* use observation in unit and preview tests instead of the async stream

* use a parsing approach that is independent of the sourcery annotation

* Fix observation deferred fulfillments not receiving the initial value

Observation's observe only emits transitions, not the value present at subscription time. Drop the leading initial value from the affected transitionValues sequences and assert it directly with #expect instead.

* improved docs
2026-06-30 14:01:08 +02:00

336 lines
15 KiB
Swift

//
// 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
import UniformTypeIdentifiers
struct NotificationSettingsScreen: View {
@Bindable var context: NotificationSettingsScreenViewModel.Context
var body: some View {
Form {
if context.viewState.settings?.inconsistentSettings.isEmpty == false {
configurationMismatchSection
} else {
if context.viewState.showSystemNotificationsAlert {
userPermissionSection
}
enableNotificationSection
if context.enableNotifications {
roomsNotificationSection
if context.viewState.settings?.roomMentionsEnabled != nil {
mentionsSection
}
if context.viewState.showCallsSettings, context.viewState.settings?.callsEnabled != nil {
callsSection
}
if context.viewState.settings?.invitationsEnabled != nil {
additionalSettingsSection
}
if context.viewState.customToneSelectionEnabled {
soundSelectionSection
}
}
}
}
.compoundList()
.navigationTitle(L10n.screenNotificationSettingsTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.alert(item: $context.alertInfo)
.track(screen: .SettingsNotifications)
}
// MARK: - Private
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.isModallyPresented {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionClose) {
context.send(viewAction: .close)
}
}
}
}
private var userPermissionSection: some View {
Section {
ListRow(kind: .custom {
HStack(alignment: .firstTextBaseline, spacing: 13) {
Image(systemSymbol: .exclamationmarkCircleFill)
.foregroundColor(.compound.iconTertiaryAlpha)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.screenNotificationSettingsSystemNotificationsTurnedOff)
.font(.compound.bodyLG)
.foregroundColor(.compound.textPrimary)
Text(context.viewState.strings.changeYourSystemSettings)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.tint(.compound.textPrimary)
}
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, 8)
})
}
}
private var enableNotificationSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenNotificationSettingsEnableNotifications),
kind: .toggle($context.enableNotifications))
.onChange(of: context.enableNotifications) {
context.send(viewAction: .changedEnableNotifications)
}
}
}
private var roomsNotificationSection: some View {
Section {
// Group chats
ListRow(label: .plain(title: L10n.screenNotificationSettingsGroupChats),
details: context.viewState.settings.map {
.title(context.viewState.strings.string(for: $0.groupChatsMode))
} ?? .isWaiting(true),
kind: .navigationLink {
context.send(viewAction: .groupChatsTapped)
})
.disabled(context.viewState.settings == nil)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications)
// Direct chats
ListRow(label: .plain(title: L10n.screenNotificationSettingsDirectChats),
details: context.viewState.settings.map {
.title(context.viewState.strings.string(for: $0.directChatsMode))
} ?? .isWaiting(true),
kind: .navigationLink {
context.send(viewAction: .directChatsTapped)
})
.disabled(context.viewState.settings == nil)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications)
} header: {
Text(L10n.screenNotificationSettingsNotificationSectionTitle)
.compoundListSectionHeader()
}
}
private var mentionsSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenNotificationSettingsRoomMentionLabel),
kind: .toggle($context.roomMentionsEnabled))
.disabled(context.viewState.settings?.roomMentionsEnabled == nil)
.allowsHitTesting(!context.viewState.applyingChange)
.onChange(of: context.roomMentionsEnabled) {
context.send(viewAction: .roomMentionChanged)
}
} header: {
Text(L10n.screenNotificationSettingsMentionsSectionTitle)
.compoundListSectionHeader()
}
}
private var callsSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenNotificationSettingsCallsLabel),
kind: .toggle($context.callsEnabled))
.disabled(context.viewState.settings?.callsEnabled == nil)
.allowsHitTesting(!context.viewState.applyingChange)
.onChange(of: context.callsEnabled) {
context.send(viewAction: .callsChanged)
}
} header: {
Text(L10n.screenNotificationSettingsAdditionalSettingsSectionTitle)
.compoundListSectionHeader()
}
}
private var additionalSettingsSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenNotificationSettingsInviteForMeLabel),
kind: .toggle($context.invitationsEnabled))
.disabled(context.viewState.settings?.invitationsEnabled == nil)
.allowsHitTesting(!context.viewState.applyingChange)
.onChange(of: context.invitationsEnabled) {
context.send(viewAction: .invitationsChanged)
}
} header: {
Text(L10n.screenNotificationSettingsAdditionalSettingsSectionTitle)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var soundSelectionSection: some View {
Section {
DisclosureGroup(context.viewState.selectedAlertTone.label, isExpanded: $context.shouldShowAlertSounds) {
customSoundSelectionSection
}
.foregroundStyle(.compound.textPrimary)
.listRowBackground(Color.compound.bgCanvasDefaultLevel1)
.listRowSeparatorTint(ListRowColor.separatorTint)
} header: {
Text(L10n.screenNotificationSettingsSoundSectionTitle)
.compoundListSectionHeader()
}
Section(isExpanded: $context.shouldShowAlertSounds) {
presetSoundSelectionSection
} header: { }
}
private var presetSoundSelectionSection: some View {
ForEach(NotificationToneManager.allDefaultAlerts, id: \.filename) { alertTone in
ListRow(label: .plain(title: alertTone.label),
kind: .selection(isSelected: context.viewState.selectedAlertTone == alertTone) {
context.send(viewAction: .selectAlertTone(alertTone))
})
}
}
@ViewBuilder
private var customSoundSelectionSection: some View {
ForEach(context.viewState.availableCustomTones, id: \.filename) { alertTone in
ListRow(label: .plain(title: alertTone.label),
kind: .selection(isSelected: context.viewState.selectedAlertTone == alertTone) {
context.send(viewAction: .selectAlertTone(alertTone))
})
}
.onDelete { indices in
let tones = indices.map {
context.viewState.availableCustomTones[$0]
}
context.send(viewAction: .deleteCustomAlertTones(tones))
}
ListRow(label: .plain(title: L10n.screenNotificationSettingsSoundCustomSoundButtonTitle),
kind: .button {
context.shouldShowCustomAlertTonePicker = true
})
.fileImporter(isPresented: $context.shouldShowCustomAlertTonePicker,
allowedContentTypes: [
.mp3,
.aiff,
.wav,
UTType("com.apple.m4a-audio"),
UTType("com.apple.coreaudio-format")
].compactMap(\.self)) {
context.send(viewAction: .addedCustomAlertTone($0))
}
}
private var configurationMismatchSection: some View {
Section {
ListRow(kind: .custom {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.screenNotificationSettingsConfigurationMismatch)
.font(.compound.bodyLGSemibold)
Text(L10n.screenNotificationSettingsConfigurationMismatchDescription)
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
}
Button {
context.send(viewAction: .fixConfigurationMismatchTapped)
} label: {
Text(L10n.actionContinue)
.frame(maxWidth: .infinity)
}
.buttonStyle(.compound(.primary, size: .medium))
.disabled(context.viewState.fixingConfigurationMismatch)
.accessibilityIdentifier(A11yIdentifiers.notificationSettingsScreen.fixMismatchConfiguration)
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, 24)
})
}
}
}
// MARK: - Previews
@available(iOS 26.0, *)
struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel: NotificationSettingsScreenViewModel = {
let appSettings = AppSettings.volatile()
let notificationCenter = UserNotificationCenterMock()
notificationCenter.authorizationStatusReturnValue = .notDetermined
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (_, true):
return .allMessages
default:
return .mentionsAndKeywordsOnly
}
}
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
notificationSettingsProxy.isCallEnabledReturnValue = false
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "John Doe"))))
var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings,
userNotificationCenter: notificationCenter,
notificationToneManager: NotificationToneManagerMock(.init()),
notificationSettingsProxy: notificationSettingsProxy,
userIndicatorController: UserIndicatorControllerMock(),
isModallyPresented: true)
viewModel.fetchInitialContent()
return viewModel
}()
static let viewModelConfigurationMismatch: NotificationSettingsScreenViewModel = {
let appSettings = AppSettings.volatile()
let notificationCenter = UserNotificationCenterMock()
notificationCenter.authorizationStatusReturnValue = .notDetermined
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
case (true, true):
return .allMessages
case (false, true):
return .mute
default:
return .mentionsAndKeywordsOnly
}
}
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
notificationSettingsProxy.isCallEnabledReturnValue = false
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "John Doe"))))
var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings,
userNotificationCenter: notificationCenter,
notificationToneManager: NotificationToneManagerMock(.init()),
notificationSettingsProxy: notificationSettingsProxy,
userIndicatorController: UserIndicatorControllerMock(),
isModallyPresented: true)
viewModel.fetchInitialContent()
return viewModel
}()
static var previews: some View {
NotificationSettingsScreen(context: viewModel.context)
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.settings).map { $0 != nil })
NotificationSettingsScreen(context: viewModelConfigurationMismatch.context)
.snapshotPreferences(expect: viewModelConfigurationMismatch.context.observe(\.viewState.settings).map { $0 != nil })
.previewDisplayName("Configuration mismatch")
}
}