Add celebration animations when voting on mood
- Create 10 full-view celebration animations (vortex, explosion, flip, shatter, pulse wave, fireworks, confetti, morph, tunnel, gravity) - Play random animation when user votes in-app - Add Animation Lab debug view to preview and test animations - Animations complete before saving mood to prevent view flash 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
358
Shared/Views/SettingsView/DebugAnimationSettingsView.swift
Normal file
358
Shared/Views/SettingsView/DebugAnimationSettingsView.swift
Normal file
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// DebugAnimationSettingsView.swift
|
||||
// Feels
|
||||
//
|
||||
// Debug-only view for experimenting with vote celebration animations.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Main Debug View
|
||||
|
||||
struct DebugAnimationSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
|
||||
|
||||
@State private var selectedAnimation: CelebrationAnimationType = .vortexCheckmark
|
||||
@State private var isAnimating = false
|
||||
|
||||
private var isDark: Bool { colorScheme == .dark }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Lab Header
|
||||
labHeader
|
||||
.padding(.top, 8)
|
||||
|
||||
// Animation Picker - Horizontal Scroll Cards
|
||||
animationPicker
|
||||
.padding(.top, 16)
|
||||
|
||||
// Preview Area
|
||||
previewArea
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(
|
||||
ZStack {
|
||||
theme.currentTheme.bg
|
||||
// Subtle grid pattern for "lab" feel
|
||||
GridPatternView()
|
||||
.opacity(isDark ? 0.03 : 0.02)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lab Header
|
||||
|
||||
private var labHeader: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "flask.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.purple, .pink],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
Text("Animation Lab")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
Text("Experiment with vote celebrations")
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Picker
|
||||
|
||||
private var animationPicker: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(CelebrationAnimationType.allCases) { type in
|
||||
AnimationCard(
|
||||
type: type,
|
||||
isSelected: selectedAnimation == type,
|
||||
isDark: isDark
|
||||
) {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
selectedAnimation = type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Area
|
||||
|
||||
private var previewArea: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Current selection indicator
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(selectedAnimation.accentColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(selectedAnimation.rawValue)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(selectedAnimation.accentColor)
|
||||
|
||||
Text("—")
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(selectedAnimation.description)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// The animated voting view
|
||||
AnimatedVotingView(
|
||||
animationType: selectedAnimation,
|
||||
isAnimating: $isAnimating
|
||||
)
|
||||
.frame(height: 280)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.fill(isDark ? Color(.systemGray6) : .white)
|
||||
.shadow(color: .black.opacity(isDark ? 0.3 : 0.08), radius: 20, x: 0, y: 8)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(selectedAnimation.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid Pattern Background
|
||||
|
||||
struct GridPatternView: View {
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let gridSize: CGFloat = 20
|
||||
for x in stride(from: 0, to: size.width, by: gridSize) {
|
||||
for y in stride(from: 0, to: size.height, by: gridSize) {
|
||||
let rect = CGRect(x: x, y: y, width: 1, height: 1)
|
||||
context.fill(Path(ellipseIn: rect), with: .color(.primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Card
|
||||
|
||||
struct AnimationCard: View {
|
||||
let type: CelebrationAnimationType
|
||||
let isSelected: Bool
|
||||
let isDark: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 8) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
isSelected
|
||||
? type.accentColor.opacity(0.2)
|
||||
: (isDark ? Color(.systemGray5) : Color(.systemGray6))
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: type.icon)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(isSelected ? type.accentColor : .secondary)
|
||||
}
|
||||
|
||||
// Name
|
||||
Text(type.rawValue)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(isSelected ? type.accentColor : .secondary)
|
||||
}
|
||||
.frame(width: 72, height: 80)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(isDark ? Color(.systemGray6) : .white)
|
||||
.shadow(
|
||||
color: isSelected ? type.accentColor.opacity(0.3) : .black.opacity(0.05),
|
||||
radius: isSelected ? 8 : 4,
|
||||
x: 0,
|
||||
y: isSelected ? 4 : 2
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(
|
||||
isSelected ? type.accentColor : Color.clear,
|
||||
lineWidth: 2
|
||||
)
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isPressed = pressing
|
||||
}
|
||||
}, perform: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animated Voting View
|
||||
|
||||
struct AnimatedVotingView: View {
|
||||
let animationType: CelebrationAnimationType
|
||||
@Binding var isAnimating: Bool
|
||||
|
||||
@State private var selectedMood: Mood = .great
|
||||
|
||||
// Animation state
|
||||
@State private var animationScale: CGFloat = 1
|
||||
@State private var animationRotation: Angle = .zero
|
||||
@State private var animationOffset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// The voting content (gets animated)
|
||||
DebugVotingContentView(selectedMood: $selectedMood) {
|
||||
triggerAnimation()
|
||||
}
|
||||
.opacity(isAnimating ? 0 : 1)
|
||||
.scaleEffect(animationScale)
|
||||
.rotation3DEffect(animationRotation, axis: (x: 0, y: 1, z: 0))
|
||||
.offset(animationOffset)
|
||||
|
||||
// Animation overlay
|
||||
if isAnimating {
|
||||
CelebrationOverlayView(
|
||||
animationType: animationType,
|
||||
mood: selectedMood
|
||||
) {
|
||||
// Reset handled by triggerAnimation
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: isAnimating)
|
||||
}
|
||||
|
||||
private func triggerAnimation() {
|
||||
guard !isAnimating else { return }
|
||||
|
||||
let impact = UIImpactFeedbackGenerator(style: .medium)
|
||||
impact.impactOccurred()
|
||||
|
||||
// Set animation properties based on type
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
isAnimating = true
|
||||
|
||||
switch animationType {
|
||||
case .vortexCheckmark:
|
||||
animationScale = 0.01
|
||||
case .explosionReveal:
|
||||
animationScale = 2.5
|
||||
case .flipReveal:
|
||||
animationRotation = .degrees(90)
|
||||
case .shatterReform:
|
||||
break
|
||||
case .pulseWave:
|
||||
animationScale = 0.9
|
||||
case .fireworks:
|
||||
animationScale = 0.8
|
||||
case .confettiCannon:
|
||||
animationScale = 0.95
|
||||
case .morphBlob:
|
||||
animationScale = 0.5
|
||||
case .zoomTunnel:
|
||||
animationScale = 0.01
|
||||
case .gravityDrop:
|
||||
animationOffset = CGSize(width: 0, height: 400)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationType.duration) {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
isAnimating = false
|
||||
animationScale = 1
|
||||
animationRotation = .zero
|
||||
animationOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug Voting Content View
|
||||
|
||||
struct DebugVotingContentView: View {
|
||||
@Binding var selectedMood: Mood
|
||||
let onVote: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("How are you feeling?")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Mood.allValues, id: \.self) { mood in
|
||||
Button {
|
||||
selectedMood = mood
|
||||
onVote()
|
||||
} label: {
|
||||
mood.icon
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(mood.color.opacity(0.15))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tap to vote")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DebugAnimationSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -53,6 +53,7 @@ struct SettingsContentView: View {
|
||||
// Debug section
|
||||
debugSectionHeader
|
||||
trialDateButton
|
||||
animationLabButton
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
@@ -242,6 +243,47 @@ struct SettingsContentView: View {
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
@State private var showAnimationLab = false
|
||||
|
||||
private var animationLabButton: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
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()
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showAnimationLab) {
|
||||
NavigationStack {
|
||||
DebugAnimationSettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Privacy Lock Toggle
|
||||
|
||||
Reference in New Issue
Block a user