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:
@@ -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:
|
||||
|
||||
@@ -205,6 +205,7 @@ class UserDefaultsStore {
|
||||
case healthKitSyncEnabled
|
||||
case paywallStyle
|
||||
case lockScreenStyle
|
||||
case celebrationAnimation
|
||||
|
||||
case contentViewCurrentSelectedHeaderViewBackDays
|
||||
case contentViewHeaderTag
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user