Merge pull request #151 from akatreyt/fix/issue-150
fix: issue #150 - add haptic feedback option
This commit is contained in:
@@ -422,6 +422,7 @@ extension AnalyticsManager {
|
|||||||
// MARK: Settings
|
// MARK: Settings
|
||||||
case settingsClosed
|
case settingsClosed
|
||||||
case deleteToggleChanged(enabled: Bool)
|
case deleteToggleChanged(enabled: Bool)
|
||||||
|
case hapticFeedbackToggled(enabled: Bool)
|
||||||
case exportTapped
|
case exportTapped
|
||||||
case dataExported(format: String, count: Int)
|
case dataExported(format: String, count: Int)
|
||||||
case importTapped
|
case importTapped
|
||||||
@@ -555,6 +556,8 @@ extension AnalyticsManager {
|
|||||||
return ("settings_closed", nil)
|
return ("settings_closed", nil)
|
||||||
case .deleteToggleChanged(let enabled):
|
case .deleteToggleChanged(let enabled):
|
||||||
return ("delete_toggle_changed", ["enabled": enabled])
|
return ("delete_toggle_changed", ["enabled": enabled])
|
||||||
|
case .hapticFeedbackToggled(let enabled):
|
||||||
|
return ("haptic_feedback_toggled", ["enabled": enabled])
|
||||||
case .exportTapped:
|
case .exportTapped:
|
||||||
return ("export_tapped", nil)
|
return ("export_tapped", nil)
|
||||||
case .dataExported(let format, let count):
|
case .dataExported(let format, let count):
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class UserDefaultsStore {
|
|||||||
case paywallStyle
|
case paywallStyle
|
||||||
case lockScreenStyle
|
case lockScreenStyle
|
||||||
case celebrationAnimation
|
case celebrationAnimation
|
||||||
|
case hapticFeedbackEnabled
|
||||||
|
|
||||||
case contentViewCurrentSelectedHeaderViewBackDays
|
case contentViewCurrentSelectedHeaderViewBackDays
|
||||||
case contentViewHeaderTag
|
case contentViewHeaderTag
|
||||||
|
|||||||
590
Shared/Services/HapticFeedbackManager.swift
Normal file
590
Shared/Services/HapticFeedbackManager.swift
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ struct AddMoodHeaderView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
@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.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.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 }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -90,14 +91,16 @@ struct AddMoodHeaderView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addItem(withMood mood: Mood) {
|
private func addItem(withMood mood: Mood) {
|
||||||
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
||||||
impactFeedback.impactOccurred()
|
|
||||||
|
|
||||||
// Store mood, date, and use saved animation preference
|
// Store mood, date, and use saved animation preference
|
||||||
celebrationMood = mood
|
celebrationMood = mood
|
||||||
celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData)
|
celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData)
|
||||||
celebrationAnimation = CelebrationAnimationType.fromIndex(celebrationAnimationIndex)
|
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
|
// Show celebration - mood will be saved when animation completes
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
showCelebration = true
|
showCelebration = true
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct SettingsContentView: View {
|
|||||||
|
|
||||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
@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.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 }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ struct SettingsContentView: View {
|
|||||||
// Settings section
|
// Settings section
|
||||||
settingsSectionHeader
|
settingsSectionHeader
|
||||||
reminderTimeButton
|
reminderTimeButton
|
||||||
|
hapticFeedbackToggle
|
||||||
canDelete
|
canDelete
|
||||||
showOnboardingButton
|
showOnboardingButton
|
||||||
|
|
||||||
@@ -1011,6 +1013,38 @@ struct SettingsContentView: View {
|
|||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.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 {
|
private var canDelete: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Toggle(String(localized: "settings_use_delete_enable"),
|
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.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.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||||
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
@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 }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -1226,6 +1261,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
settingsSectionHeader
|
settingsSectionHeader
|
||||||
|
hapticFeedbackToggle
|
||||||
canDelete
|
canDelete
|
||||||
showOnboardingButton
|
showOnboardingButton
|
||||||
|
|
||||||
@@ -1823,6 +1859,38 @@ struct SettingsView: View {
|
|||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.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 {
|
private var canDelete: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Toggle(String(localized: "settings_use_delete_enable"),
|
Toggle(String(localized: "settings_use_delete_enable"),
|
||||||
@@ -1837,7 +1905,7 @@ struct SettingsView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var exportData: some View {
|
private var exportData: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingExporter.toggle()
|
showingExporter.toggle()
|
||||||
|
|||||||
Reference in New Issue
Block a user