feat(settings): Notifications section skeleton + All-notifications toggle

Marks .notifications implemented, adds the shared setNotifications save
path (per-event subscribe/unsubscribe, sequential calls for toggles that
group more than one wire event type) and the master "All notifications"
row. Individual event toggles land in follow-up commits.
This commit is contained in:
2026-08-17 21:52:07 +01:00
parent c05b787bb7
commit bf1e28c9ac
2 changed files with 82 additions and 1 deletions
@@ -39,6 +39,8 @@ struct SettingsView: View {
@State private var isShowingDeleteAccountConfirmation = false
@State private var isDeletingAccount = false
@State private var deleteAccountErrorMessage: String?
@State private var isSavingNotifications = false
@State private var notificationsErrorMessage: String?
/// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it
@@ -79,6 +81,7 @@ struct SettingsView: View {
case .appearance: appearanceDetail
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
@@ -544,6 +547,84 @@ struct SettingsView: View {
}
}
// MARK: - Notifications
private var notificationsDetail: some View {
VStack(alignment: .leading, spacing: 14) {
sectionHeader
Text("Manage when and where you receive email notifications.")
.font(.subheadline)
.foregroundStyle(.secondary)
if !isEffectivelyOnline {
offlineSettingsHint
}
allNotificationsRow
Divider()
if let notificationsErrorMessage {
Text(notificationsErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
.disabled(!isEffectivelyOnline)
.frame(maxWidth: 480, alignment: .leading)
.task { await refreshProfile() }
}
private var allNotificationsRow: some View {
let isOn = NotificationEventType.allCases.allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false }
return notificationToggleRow(
"All notifications",
description: nil,
isOn: isOn,
onChange: { newValue in Task { await setNotifications(nil, subscribed: newValue) } }
)
}
private func notificationToggleRow(
_ title: String,
description: String?,
isOn: Bool,
onChange: @escaping (Bool) -> Void
) -> some View {
Toggle(isOn: Binding(get: { isOn }, set: onChange)) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
if let description {
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
/// `eventTypes` groups more than one wire event under a single visible
/// toggle (e.g. "Mentioned" covers both `comments.mentioned` and
/// `documents.mentioned`) confirmed as the right grouping against
/// Outline's own settings copy, applied with one subscribe/unsubscribe
/// call per underlying event type, sequentially.
private func setNotifications(_ eventTypes: [NotificationEventType]?, subscribed: Bool) async {
guard let apiClient = session.apiClient else { return }
isSavingNotifications = true
defer { isSavingNotifications = false }
let targets: [NotificationEventType?] = eventTypes ?? [nil]
do {
for target in targets {
let updated = subscribed
? try await apiClient.subscribeToNotifications(eventType: target)
: try await apiClient.unsubscribeFromNotifications(eventType: target)
session.applyUpdatedProfile(updated)
}
notificationsErrorMessage = nil
} catch {
notificationsErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your notification settings.")
}
}
// MARK: - Offline & Sync
private var offlineSyncDetail: some View {
+1 -1
View File
@@ -122,7 +122,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// placeholder until its content is specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences:
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications:
return true
default:
return false