Move vote animation to Customize tab as persistent user setting

Replace random animation selection with a user-configurable picker on
the Customize tab between Mood Style and Notifications. Confetti is
the default. Selecting a style shows an inline preview that auto-plays
the animation then dismisses itself. Remove Animation Lab from Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-24 11:47:40 -06:00
parent c643feb1d6
commit 36be57e47d
6 changed files with 193 additions and 51 deletions

View File

@@ -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:

View File

@@ -205,6 +205,7 @@ class UserDefaultsStore {
case healthKitSyncEnabled
case paywallStyle
case lockScreenStyle
case celebrationAnimation
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag

View File

@@ -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)) {

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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