vibetunnel/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift
Peter Steinberger a2bd642053 Fix duplicate Git worktree button in mobile view
- Move worktree toggle button inside responsive container
- Button now properly hides when compact menu is shown
- Prevents redundant display of same functionality on mobile
2025-07-28 13:24:17 +02:00

241 lines
11 KiB
Swift

import AppKit
import os.log
import SwiftUI
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "NotificationSettings")
/// Settings view for managing notification preferences
struct NotificationSettingsView: View {
@AppStorage("showNotifications")
private var showNotifications = true
@Environment(ConfigManager.self) private var configManager
@Environment(NotificationService.self) private var notificationService
@State private var isTestingNotification = false
@State private var showingPermissionAlert = false
private func updateNotificationPreferences() {
// Load current preferences from ConfigManager and notify the service
let prefs = NotificationService.NotificationPreferences(fromConfig: configManager)
notificationService.updatePreferences(prefs)
// Also update the enabled state in ConfigManager
configManager.notificationsEnabled = showNotifications
}
var body: some View {
NavigationStack {
@Bindable var bindableConfig = configManager
Form {
// Master toggle section
Section {
VStack(alignment: .leading, spacing: 12) {
Toggle("Show Session Notifications", isOn: $showNotifications)
.controlSize(.large)
.onChange(of: showNotifications) { _, newValue in
// Update ConfigManager's notificationsEnabled to match
configManager.notificationsEnabled = newValue
// Ensure NotificationService starts/stops based on the toggle
if newValue {
Task {
// Request permissions and show test notification
let granted = await notificationService
.requestPermissionAndShowTestNotification()
if granted {
await notificationService.start()
} else {
// If permission denied, turn toggle back off
await MainActor.run {
showNotifications = false
configManager.notificationsEnabled = false
showingPermissionAlert = true
}
}
}
} else {
notificationService.stop()
}
}
Text("Display native macOS notifications for session and command events")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
}
// Notification types section
if showNotifications {
Section {
NotificationToggleRow(
title: "Session starts",
description: "When a new session starts (useful for shared terminals)",
isOn: $bindableConfig.notificationSessionStart
)
.onChange(of: bindableConfig.notificationSessionStart) { _, _ in
updateNotificationPreferences()
}
NotificationToggleRow(
title: "Session ends",
description: "When a session terminates or crashes (shows exit code)",
isOn: $bindableConfig.notificationSessionExit
)
.onChange(of: bindableConfig.notificationSessionExit) { _, _ in
updateNotificationPreferences()
}
NotificationToggleRow(
title: "Commands fail",
description: "When commands fail with non-zero exit codes",
isOn: $bindableConfig.notificationCommandError
)
.onChange(of: bindableConfig.notificationCommandError) { _, _ in
updateNotificationPreferences()
}
NotificationToggleRow(
title: "Commands complete (> 3 seconds)",
description: "When commands taking >3 seconds finish (builds, tests, etc.)",
isOn: $bindableConfig.notificationCommandCompletion
)
.onChange(of: bindableConfig.notificationCommandCompletion) { _, _ in
updateNotificationPreferences()
}
NotificationToggleRow(
title: "Terminal bell (🔔)",
description: "Terminal bell (^G) from vim, IRC mentions, completion sounds",
isOn: $bindableConfig.notificationBell
)
.onChange(of: bindableConfig.notificationBell) { _, _ in
updateNotificationPreferences()
}
NotificationToggleRow(
title: "Claude turn notifications",
description: "When Claude AI finishes responding and awaits input",
isOn: $bindableConfig.notificationClaudeTurn
)
.onChange(of: bindableConfig.notificationClaudeTurn) { _, _ in
updateNotificationPreferences()
}
} header: {
Text("Notification Types")
.font(.headline)
}
// Behavior section
Section {
VStack(spacing: 12) {
Toggle("Play sound", isOn: $bindableConfig.notificationSoundEnabled)
.onChange(of: bindableConfig.notificationSoundEnabled) { _, _ in
updateNotificationPreferences()
}
Toggle("Show in Notification Center", isOn: $bindableConfig.showInNotificationCenter)
.onChange(of: bindableConfig.showInNotificationCenter) { _, _ in
updateNotificationPreferences()
}
}
} header: {
Text("Notification Behavior")
.font(.headline)
}
// Test section
Section {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Test Notification") {
Task {
isTestingNotification = true
await notificationService.sendGenericNotification(
title: "VibeTunnel Test",
body: "This is a test notification to verify your settings are working correctly."
)
// Reset button state after a delay
try? await Task.sleep(nanoseconds: 1_000_000_000)
isTestingNotification = false
}
}
.buttonStyle(.bordered)
.disabled(!showNotifications || isTestingNotification)
if isTestingNotification {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
}
Spacer()
}
HStack {
Button("Open System Settings") {
notificationService.openNotificationSettings()
}
.buttonStyle(.link)
Spacer()
}
}
} header: {
Text("Actions")
.font(.headline)
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle("Notification Settings")
.onAppear {
// Sync the AppStorage value with ConfigManager on first load
showNotifications = configManager.notificationsEnabled
}
}
.alert("Notification Permission Required", isPresented: $showingPermissionAlert) {
Button("Open System Settings") {
notificationService.openNotificationSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings."
)
}
}
}
/// Reusable component for notification toggle rows with descriptions
struct NotificationToggleRow: View {
let title: String
let description: String
@Binding var isOn: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
}
.padding(.vertical, 6)
}
}
#Preview {
NotificationSettingsView()
.environment(ConfigManager.shared)
.environment(NotificationService.shared)
.frame(width: 560, height: 700)
}