From 1303cb8cbc5f8ebc8d055911f163dfa2bcbeee1e Mon Sep 17 00:00:00 2001 From: treyt Date: Tue, 10 Mar 2026 16:13:08 -0500 Subject: [PATCH] fix: issue #150 - add haptic feedback option Automated fix by Tony CI v3. Refs #150 Co-Authored-By: Claude --- Shared/Analytics.swift | 3 + Shared/Models/UserDefaultsStore.swift | 1 + Shared/Services/HapticFeedbackManager.swift | 590 +++++++++++++++++++ Shared/Views/AddMoodHeaderView.swift | 9 +- Shared/Views/SettingsView/SettingsView.swift | 70 ++- 5 files changed, 669 insertions(+), 4 deletions(-) create mode 100644 Shared/Services/HapticFeedbackManager.swift diff --git a/Shared/Analytics.swift b/Shared/Analytics.swift index 3765fe4..e4cb610 100644 --- a/Shared/Analytics.swift +++ b/Shared/Analytics.swift @@ -422,6 +422,7 @@ extension AnalyticsManager { // MARK: Settings case settingsClosed case deleteToggleChanged(enabled: Bool) + case hapticFeedbackToggled(enabled: Bool) case exportTapped case dataExported(format: String, count: Int) case importTapped @@ -555,6 +556,8 @@ extension AnalyticsManager { return ("settings_closed", nil) case .deleteToggleChanged(let enabled): return ("delete_toggle_changed", ["enabled": enabled]) + case .hapticFeedbackToggled(let enabled): + return ("haptic_feedback_toggled", ["enabled": enabled]) case .exportTapped: return ("export_tapped", nil) case .dataExported(let format, let count): diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index 3b4ade1..a3dab09 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -206,6 +206,7 @@ class UserDefaultsStore { case paywallStyle case lockScreenStyle case celebrationAnimation + case hapticFeedbackEnabled case contentViewCurrentSelectedHeaderViewBackDays case contentViewHeaderTag diff --git a/Shared/Services/HapticFeedbackManager.swift b/Shared/Services/HapticFeedbackManager.swift new file mode 100644 index 0000000..d1cdd54 --- /dev/null +++ b/Shared/Services/HapticFeedbackManager.swift @@ -0,0 +1,590 @@ +// +// HapticFeedbackManager.swift +// Reflect +// +// Haptic feedback patterns that match celebration animations. +// + +import UIKit +import CoreHaptics + +@MainActor +final class HapticFeedbackManager { + static let shared = HapticFeedbackManager() + + private var engine: CHHapticEngine? + + private init() { + prepareEngine() + } + + private func prepareEngine() { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } + do { + engine = try CHHapticEngine() + engine?.resetHandler = { [weak self] in + try? self?.engine?.start() + } + try engine?.start() + } catch { + engine = nil + } + } + + /// Play a haptic pattern that matches the given celebration animation type. + func play(for animationType: CelebrationAnimationType) { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics, + let engine else { + // Fall back to basic haptic on devices without CoreHaptics + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + return + } + + do { + let pattern = try hapticPattern(for: animationType) + let player = try engine.makePlayer(with: pattern) + try engine.start() + try player.start(atTime: CHHapticTimeImmediate) + } catch { + // Fallback to simple impact + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + } + } + + // MARK: - Pattern Definitions + + private func hapticPattern(for animationType: CelebrationAnimationType) throws -> CHHapticPattern { + switch animationType { + case .confettiCannon: + return try confettiPattern() + case .vortexCheckmark: + return try vortexPattern() + case .explosionReveal: + return try explosionPattern() + case .flipReveal: + return try flipPattern() + case .shatterReform: + return try shatterPattern() + case .pulseWave: + return try pulseWavePattern() + case .fireworks: + return try fireworksPattern() + case .morphBlob: + return try morphPattern() + case .zoomTunnel: + return try tunnelPattern() + case .gravityDrop: + return try gravityPattern() + } + } + + // Confetti: burst upward, then scattered taps falling down + private func confettiPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Initial burst + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + ], + relativeTime: 0.0 + )) + + // Glow swell + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.1, + duration: 0.3 + )) + + // Scattered confetti taps falling + let confettiTimes: [Double] = [0.5, 0.6, 0.65, 0.75, 0.85, 0.95, 1.1, 1.2] + for (i, time) in confettiTimes.enumerated() { + let intensity = Float(0.6 - Double(i) * 0.05) + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: max(intensity, 0.15)), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: time + )) + } + + // Final checkmark thud + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 1.4 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Vortex: spinning wind-up then a satisfying snap + private func vortexPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Spiral wind-up (continuous, rising intensity) + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.0, + duration: 0.5 + )) + + // Ring snap + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) + ], + relativeTime: 0.5 + )) + + // Checkmark bounce + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.6) + ], + relativeTime: 0.7 + )) + + // Settle + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: 0.9 + )) + + let curve = CHHapticParameterCurve( + parameterID: .hapticIntensityControl, + controlPoints: [ + CHHapticParameterCurve.ControlPoint(relativeTime: 0.0, value: 0.2), + CHHapticParameterCurve.ControlPoint(relativeTime: 0.4, value: 0.7), + CHHapticParameterCurve.ControlPoint(relativeTime: 0.5, value: 1.0) + ], + relativeTime: 0.0 + ) + + return try CHHapticPattern(events: events, parameterCurves: [curve]) + } + + // Explosion: big bang then particles scatter + private func explosionPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Big explosion impact + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0) + ], + relativeTime: 0.0 + )) + + // Debris rumble + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + ], + relativeTime: 0.05, + duration: 0.4 + )) + + // Glow pulse + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2) + ], + relativeTime: 0.3, + duration: 0.3 + )) + + // Checkmark landing + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 0.6 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Flip: a quick flip-over thud + private func flipPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Card lift + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.0 + )) + + // Mid-flip whoosh + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: 0.2, + duration: 0.2 + )) + + // Card lands + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.6) + ], + relativeTime: 0.4 + )) + + // Shimmer glide + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) + ], + relativeTime: 0.5, + duration: 0.4 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Shatter: crack apart then reform + private func shatterPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Shatter crack + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0) + ], + relativeTime: 0.0 + )) + + // Flying shards + let shardTimes: [Double] = [0.05, 0.1, 0.15, 0.2, 0.3] + for (i, time) in shardTimes.enumerated() { + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(0.7 - Double(i) * 0.1)), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + ], + relativeTime: time + )) + } + + // Reform convergence + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.6, + duration: 0.4 + )) + + // Checkmark snap + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 1.1 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Pulse Wave: expanding rings + private func pulseWavePattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Three expanding wave pulses + let waveTimes: [Double] = [0.0, 0.1, 0.2] + for (i, time) in waveTimes.enumerated() { + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(0.8 - Double(i) * 0.15)), + CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(0.6 - Double(i) * 0.1)) + ], + relativeTime: time + )) + } + + // Stamp down (checkmark) + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) + ], + relativeTime: 0.3 + )) + + // Stamp settle + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: 0.45 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Fireworks: multiple bursts at staggered intervals + private func fireworksPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // 4 firework bursts, each staggered + let burstTimes: [Double] = [0.0, 0.15, 0.3, 0.45] + for time in burstTimes { + // Launch whoosh + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: time, + duration: 0.1 + )) + + // Burst pop + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + ], + relativeTime: time + 0.1 + )) + } + + // Final checkmark + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 0.7 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Morph: flowing organic movement + private func morphPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Expand outward + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2) + ], + relativeTime: 0.0, + duration: 0.4 + )) + + // Morph shift + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.4, + duration: 0.5 + )) + + // Converge + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: 0.9 + )) + + // Checkmark reveal + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 1.2 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Tunnel: rushing zoom then arrival + private func tunnelPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Rings rushing inward (6 quick taps, increasing intensity) + for i in 0..<6 { + let time = Double(i) * 0.08 + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(0.2 + Double(i) * 0.12)), + CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(0.3 + Double(i) * 0.1)) + ], + relativeTime: time + )) + } + + // Warp speed continuous + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.6) + ], + relativeTime: 0.3, + duration: 0.2 + )) + + // Arrival impact + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7) + ], + relativeTime: 0.5 + )) + + // Settle bounce + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4) + ], + relativeTime: 0.7 + )) + + return try CHHapticPattern(events: events, parameters: []) + } + + // Gravity: fall, bounce, bounce, settle + private func gravityPattern() throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + + // Falling whoosh + events.append(CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.0, + duration: 0.35 + )) + + // First impact (heaviest) + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + ], + relativeTime: 0.4 + )) + + // First bounce up & down + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.6) + ], + relativeTime: 0.6 + )) + + // Second bounce (smaller) + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.35), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5) + ], + relativeTime: 0.75 + )) + + // Final settle + events.append(CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3) + ], + relativeTime: 0.85 + )) + + let curve = CHHapticParameterCurve( + parameterID: .hapticIntensityControl, + controlPoints: [ + CHHapticParameterCurve.ControlPoint(relativeTime: 0.0, value: 0.1), + CHHapticParameterCurve.ControlPoint(relativeTime: 0.35, value: 0.8) + ], + relativeTime: 0.0 + ) + + return try CHHapticPattern(events: events, parameterCurves: [curve]) + } +} diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index e9f7b24..e3d77c8 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -15,6 +15,7 @@ struct AddMoodHeaderView: View { @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 @AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0 + @AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true private var textColor: Color { theme.currentTheme.labelColor } @@ -90,14 +91,16 @@ struct AddMoodHeaderView: View { } private func addItem(withMood mood: Mood) { - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - // Store mood, date, and use saved animation preference celebrationMood = mood celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData) celebrationAnimation = CelebrationAnimationType.fromIndex(celebrationAnimationIndex) + // Play haptic feedback matching the selected animation + if hapticFeedbackEnabled { + HapticFeedbackManager.shared.play(for: celebrationAnimation) + } + // Show celebration - mood will be saved when animation completes withAnimation(.easeInOut(duration: 0.3)) { showCelebration = true diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index 053375f..a0cacab 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -38,6 +38,7 @@ struct SettingsContentView: View { @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + @AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true private var textColor: Color { theme.currentTheme.labelColor } @@ -53,6 +54,7 @@ struct SettingsContentView: View { // Settings section settingsSectionHeader reminderTimeButton + hapticFeedbackToggle canDelete showOnboardingButton @@ -1011,6 +1013,38 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + private var hapticFeedbackToggle: some View { + HStack(spacing: 12) { + Image(systemName: "waveform") + .font(.title2) + .foregroundColor(.purple) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Haptic Feedback")) + .foregroundColor(textColor) + + Text(String(localized: "Vibrate when logging mood")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: $hapticFeedbackEnabled) + .labelsHidden() + .onChange(of: hapticFeedbackEnabled) { _, newValue in + AnalyticsManager.shared.track(.hapticFeedbackToggled(enabled: newValue)) + } + .accessibilityLabel(String(localized: "Haptic Feedback")) + .accessibilityHint(String(localized: "Toggle vibration feedback when voting")) + } + .padding() + .background(theme.currentTheme.secondaryBGColor) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var canDelete: some View { VStack { Toggle(String(localized: "settings_use_delete_enable"), @@ -1205,6 +1239,7 @@ struct SettingsView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false + @AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true private var textColor: Color { theme.currentTheme.labelColor } @@ -1226,6 +1261,7 @@ struct SettingsView: View { // Settings section settingsSectionHeader + hapticFeedbackToggle canDelete showOnboardingButton @@ -1823,6 +1859,38 @@ struct SettingsView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + private var hapticFeedbackToggle: some View { + HStack(spacing: 12) { + Image(systemName: "waveform") + .font(.title2) + .foregroundColor(.purple) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Haptic Feedback")) + .foregroundColor(textColor) + + Text(String(localized: "Vibrate when logging mood")) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: $hapticFeedbackEnabled) + .labelsHidden() + .onChange(of: hapticFeedbackEnabled) { _, newValue in + AnalyticsManager.shared.track(.hapticFeedbackToggled(enabled: newValue)) + } + .accessibilityLabel(String(localized: "Haptic Feedback")) + .accessibilityHint(String(localized: "Toggle vibration feedback when voting")) + } + .padding() + .background(theme.currentTheme.secondaryBGColor) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var canDelete: some View { VStack { Toggle(String(localized: "settings_use_delete_enable"), @@ -1837,7 +1905,7 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - + private var exportData: some View { Button(action: { showingExporter.toggle()