diff --git a/Shared/Analytics.swift b/Shared/Analytics.swift index 8990348..54bf6cc 100644 --- a/Shared/Analytics.swift +++ b/Shared/Analytics.swift @@ -390,6 +390,7 @@ extension AnalyticsManager { case moodShapeChanged(shapeId: Int) case personalityPackChanged(packTitle: String) case appIconChanged(iconTitle: String) + case celebrationAnimationChanged(animation: String) // MARK: Widget case widgetViewed @@ -498,6 +499,8 @@ extension AnalyticsManager { return ("personality_pack_changed", ["pack_title": title]) case .appIconChanged(let title): return ("app_icon_changed", ["icon_title": title]) + case .celebrationAnimationChanged(let animation): + return ("celebration_animation_changed", ["animation": animation]) // Widget case .widgetViewed: diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index 096f3ac..e3d4efd 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -205,6 +205,7 @@ class UserDefaultsStore { case healthKitSyncEnabled case paywallStyle case lockScreenStyle + case celebrationAnimation case contentViewCurrentSelectedHeaderViewBackDays case contentViewHeaderTag diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index 1b275a1..ee2cb9e 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -14,6 +14,7 @@ struct AddMoodHeaderView: View { @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @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 private var textColor: Color { theme.currentTheme.labelColor } @@ -92,10 +93,10 @@ struct AddMoodHeaderView: View { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - // Store mood, date, and pick random animation + // Store mood, date, and use saved animation preference celebrationMood = mood celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData) - celebrationAnimation = .random + celebrationAnimation = CelebrationAnimationType.fromIndex(celebrationAnimationIndex) // Show celebration - mood will be saved when animation completes withAnimation(.easeInOut(duration: 0.3)) { diff --git a/Shared/Views/CelebrationAnimations.swift b/Shared/Views/CelebrationAnimations.swift index 31ac11b..95dfb00 100644 --- a/Shared/Views/CelebrationAnimations.swift +++ b/Shared/Views/CelebrationAnimations.swift @@ -10,28 +10,39 @@ import SwiftUI // MARK: - Animation Type Enum enum CelebrationAnimationType: String, CaseIterable, Identifiable { + case confettiCannon = "Confetti" case vortexCheckmark = "Vortex" case explosionReveal = "Explosion" case flipReveal = "Flip" case shatterReform = "Shatter" case pulseWave = "Pulse Wave" case fireworks = "Fireworks" - case confettiCannon = "Confetti" case morphBlob = "Morph" case zoomTunnel = "Tunnel" case gravityDrop = "Gravity" var id: String { rawValue } + /// Int index for AppStorage persistence (enum uses String raw values) + var index: Int { + Self.allCases.firstIndex(of: self) ?? 0 + } + + /// Restore from persisted Int index + static func fromIndex(_ index: Int) -> CelebrationAnimationType { + guard index >= 0, index < allCases.count else { return .confettiCannon } + return allCases[index] + } + var icon: String { switch self { + case .confettiCannon: return "party.popper.fill" case .vortexCheckmark: return "tornado" case .explosionReveal: return "sparkles" case .flipReveal: return "rectangle.portrait.rotate" case .shatterReform: return "square.grid.3x3" case .pulseWave: return "dot.radiowaves.left.and.right" case .fireworks: return "fireworks" - case .confettiCannon: return "party.popper.fill" case .morphBlob: return "drop.fill" case .zoomTunnel: return "tunnel.circle" case .gravityDrop: return "arrow.down.to.line" @@ -40,13 +51,13 @@ enum CelebrationAnimationType: String, CaseIterable, Identifiable { var description: String { switch self { + case .confettiCannon: return "Party confetti" case .vortexCheckmark: return "Sucked into center" case .explosionReveal: return "Explodes outward" case .flipReveal: return "3D card flip" case .shatterReform: return "Shatters & reforms" case .pulseWave: return "Expanding waves" case .fireworks: return "Celebration burst" - case .confettiCannon: return "Party confetti" case .morphBlob: return "Liquid transform" case .zoomTunnel: return "Warp speed zoom" case .gravityDrop: return "Falls with bounce" @@ -55,13 +66,13 @@ enum CelebrationAnimationType: String, CaseIterable, Identifiable { var accentColor: Color { switch self { + case .confettiCannon: return .pink case .vortexCheckmark: return .purple case .explosionReveal: return .orange case .flipReveal: return .blue case .shatterReform: return .red case .pulseWave: return .green case .fireworks: return .yellow - case .confettiCannon: return .pink case .morphBlob: return .cyan case .zoomTunnel: return .indigo case .gravityDrop: return .mint @@ -70,13 +81,13 @@ enum CelebrationAnimationType: String, CaseIterable, Identifiable { var duration: Double { switch self { + case .confettiCannon: return 2.2 case .vortexCheckmark: return 2.0 case .explosionReveal: return 1.8 case .flipReveal: return 1.5 case .shatterReform: return 2.2 case .pulseWave: return 1.6 case .fireworks: return 2.5 - case .confettiCannon: return 2.2 case .morphBlob: return 2.0 case .zoomTunnel: return 1.8 case .gravityDrop: return 2.0 @@ -84,7 +95,7 @@ enum CelebrationAnimationType: String, CaseIterable, Identifiable { } static var random: CelebrationAnimationType { - allCases.randomElement() ?? .pulseWave + allCases.randomElement() ?? .confettiCannon } } diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index ae45d0f..dbefd6f 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -93,6 +93,11 @@ struct CustomizeContentView: View { } } + // VOTE ANIMATION + SettingsSection(title: "Vote Animation") { + CelebrationAnimationPickerCompact() + } + // WIDGETS // SettingsSection(title: "Widgets") { // CustomWidgetSection() @@ -162,6 +167,11 @@ struct CustomizeView: View { } } + // VOTE ANIMATION + SettingsSection(title: "Vote Animation") { + CelebrationAnimationPickerCompact() + } + // WIDGETS // SettingsSection(title: "Widgets") { // CustomWidgetSection() @@ -480,6 +490,165 @@ struct VotingLayoutPickerCompact: View { } } +// MARK: - Celebration Animation Picker +struct CelebrationAnimationPickerCompact: View { + @AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0 + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + @Environment(\.colorScheme) private var colorScheme + + // Preview state + @State private var previewAnimation: CelebrationAnimationType? + @State private var previewMood: Mood = .great + @State private var showPreviewCelebration = false + @State private var previewScale: CGFloat = 1.0 + @State private var previewOpacity: Double = 1.0 + + private var currentAnimation: CelebrationAnimationType { + CelebrationAnimationType.fromIndex(celebrationAnimationIndex) + } + + var body: some View { + VStack(spacing: 0) { + // Animation style picker + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(CelebrationAnimationType.allCases) { animation in + Button(action: { + selectAnimation(animation) + }) { + VStack(spacing: 6) { + Image(systemName: animation.icon) + .font(.title2) + .frame(width: 44, height: 44) + .foregroundColor(currentAnimation == animation ? animation.accentColor : theme.currentTheme.labelColor.opacity(0.4)) + + Text(animation.rawValue) + .font(.caption2.weight(.medium)) + .foregroundColor(currentAnimation == animation ? animation.accentColor : theme.currentTheme.labelColor.opacity(0.5)) + } + .frame(width: 70) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(currentAnimation == animation + ? animation.accentColor.opacity(0.1) + : (colorScheme == .dark ? Color(.systemGray5) : .white)) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + } + + // Inline animation preview + if let animation = previewAnimation { + AnimationPreviewView( + animation: animation, + mood: previewMood, + moodTint: moodTint, + showCelebration: showPreviewCelebration, + onCelebrationComplete: { + dismissPreview() + } + ) + .scaleEffect(previewScale) + .opacity(previewOpacity) + .padding(.top, 16) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + } + + private func selectAnimation(_ animation: CelebrationAnimationType) { + // Save preference + if UIAccessibility.isReduceMotionEnabled { + celebrationAnimationIndex = animation.index + } else { + withAnimation(.easeInOut(duration: 0.2)) { + celebrationAnimationIndex = animation.index + } + } + AnalyticsManager.shared.track(.celebrationAnimationChanged(animation: animation.rawValue)) + + // Reset and show preview + previewScale = 1.0 + previewOpacity = 1.0 + showPreviewCelebration = false + previewMood = Mood.allValues.randomElement() ?? .great + + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + previewAnimation = animation + } + + // Auto-trigger the celebration after a brief pause + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.5)) + guard previewAnimation == animation else { return } + withAnimation(.easeInOut(duration: 0.3)) { + showPreviewCelebration = true + } + } + } + + private func dismissPreview() { + withAnimation(.easeIn(duration: 0.3)) { + previewScale = 0.6 + previewOpacity = 0 + } + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.35)) + withAnimation(.easeOut(duration: 0.15)) { + previewAnimation = nil + } + } + } +} + +// MARK: - Animation Preview (inline in Customize) +private struct AnimationPreviewView: View { + let animation: CelebrationAnimationType + let mood: Mood + let moodTint: MoodTints + let showCelebration: Bool + let onCelebrationComplete: () -> Void + + var body: some View { + ZStack { + // Mini voting row (fades out when celebration starts) + HStack(spacing: 12) { + ForEach(Mood.allValues, id: \.self) { m in + m.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .foregroundColor(moodTint.color(forMood: m)) + .scaleEffect(showCelebration && m == mood ? 1.3 : 1.0) + .opacity(showCelebration && m != mood ? 0.3 : 1.0) + } + } + .opacity(showCelebration ? 0 : 1) + + // Celebration overlay + if showCelebration { + CelebrationOverlayView( + animationType: animation, + mood: mood, + onComplete: onCelebrationComplete + ) + } + } + .frame(height: 160) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.systemGray6).opacity(0.5)) + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + // MARK: - Custom Widget Section struct CustomWidgetSection: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index cc9abdc..9699480 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -62,8 +62,6 @@ struct SettingsContentView: View { privacyButton analyticsToggle - animationLabButton - #if DEBUG // Debug section debugSectionHeader @@ -200,47 +198,6 @@ struct SettingsContentView: View { .padding(.horizontal, 4) } - // MARK: - Animation Lab - - @State private var showAnimationLab = false - - private var animationLabButton: some View { - Button { - showAnimationLab = true - } label: { - HStack(spacing: 12) { - Image(systemName: "sparkles") - .font(.title2) - .foregroundColor(.purple) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Animation Lab") - .foregroundColor(textColor) - - Text("Experiment with vote celebrations") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding() - } - .background(theme.currentTheme.secondaryBGColor) - .fixedSize(horizontal: false, vertical: true) - .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - .sheet(isPresented: $showAnimationLab) { - NavigationStack { - DebugAnimationSettingsView() - } - } - } - // MARK: - Debug Section #if DEBUG