forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGoalCelebrationView.swift
More file actions
174 lines (157 loc) · 5.48 KB
/
Copy pathGoalCelebrationView.swift
File metadata and controls
174 lines (157 loc) · 5.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import OmiTheme
import SwiftUI
/// Fullscreen overlay celebration when a goal is completed
struct GoalCelebrationView: View {
@State private var showCelebration = false
@State private var completedGoal: Goal?
@State private var phase: CelebrationPhase = .idle
enum CelebrationPhase {
case idle, dim, confetti, text, fadeOut
}
var body: some View {
ZStack {
if showCelebration {
// The dim behind the confetti. Bounded to the shell's surface (`ShellModalScrim`): this view
// is an overlay on the whole transparent main window, so full-bleed it painted a dark
// rectangle over the desktop around the app. It takes no clicks — a celebration must not
// steal the pointer — and it ramps from nothing rather than between two near-equal values.
ShellModalScrim(
opacity: phase == .idle || phase == .fadeOut ? 0 : ShellModalScrimLayout.dim,
blocksInteraction: false)
// Confetti burst
if phase == .confetti || phase == .text {
GoalConfettiView()
.allowsHitTesting(false)
.transition(.opacity)
}
// Celebration text
if phase == .text, let goal = completedGoal {
VStack(spacing: OmiSpacing.lg) {
Text("Goal completed")
.scaledFont(size: 32, weight: .bold)
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange, .yellow],
startPoint: .leading,
endPoint: .trailing
)
)
.shadow(color: .yellow.opacity(0.6), radius: 12)
Text(goal.title)
.scaledFont(size: OmiType.heading, weight: .medium)
.foregroundColor(Ink.primary)
.multilineTextAlignment(.center)
.padding(.horizontal, OmiSpacing.page)
Text("\(Int(goal.targetValue)) \(goal.unit ?? "") reached")
.scaledFont(size: OmiType.body)
.foregroundColor(Ink.secondary)
}
.transition(.scale(scale: 0.7).combined(with: .opacity))
}
}
}
.omiAnimation(.easeInOut(duration: 0.3), value: phase)
.onReceive(NotificationCenter.default.publisher(for: .goalCompleted)) { notification in
if let goal = notification.object as? Goal {
triggerCelebration(goal: goal)
}
}
}
private func triggerCelebration(goal: Goal) {
completedGoal = goal
showCelebration = true
log("CELEBRATION: Goal '\(goal.title)' completed, starting animation")
// Phase 1: Dim (immediate)
OmiMotion.withGated(.easeOut(duration: 0.3)) {
phase = .dim
}
// Phase 2: Confetti burst (after 0.3s)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
OmiMotion.withGated(.easeOut(duration: 0.3)) {
phase = .confetti
}
}
// Phase 3: Text appears (after 0.8s)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
OmiMotion.withGated(.spring(response: 0.5, dampingFraction: 0.7)) {
phase = .text
}
}
// Phase 4: Fade out (after 3.0s)
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
OmiMotion.withGated(.easeOut(duration: 0.5)) {
phase = .fadeOut
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showCelebration = false
phase = .idle
completedGoal = nil
}
}
}
}
// MARK: - Goal Confetti View
/// Large confetti burst for goal completion celebration
struct GoalConfettiView: View {
@State private var animate = false
@State private var fadeOut = false
private let particleConfigs:
[(color: Color, size: CGFloat, angle: Double, distance: CGFloat, rotation: Double, isRect: Bool)] = {
let colors: [Color] = [
.yellow, Color(red: 1.0, green: 0.84, blue: 0), // Gold
Color(red: 0.133, green: 0.773, blue: 0.369), // Green
Color(red: 0.2, green: 0.6, blue: 1.0), // Blue
.pink, .orange, .cyan, .mint,
Ink.accent, Ink.accent.opacity(0.7),
]
return (0..<40).map { _ in
(
color: colors.randomElement()!,
size: CGFloat.random(in: 4...10),
angle: Double.random(in: 0...(2 * .pi)),
distance: CGFloat.random(in: 80...300),
rotation: Double.random(in: 0...1080),
isRect: Bool.random()
)
}
}()
var body: some View {
GeometryReader { geo in
let cx = geo.size.width / 2
let cy = geo.size.height / 2
ZStack {
ForEach(0..<particleConfigs.count, id: \.self) { i in
let p = particleConfigs[i]
Group {
if p.isRect {
RoundedRectangle(cornerRadius: 1.5)
.fill(p.color)
} else {
Circle()
.fill(p.color)
}
}
.frame(width: p.size, height: p.size * (p.isRect ? 2.5 : 1))
.rotationEffect(.degrees(animate ? p.rotation : 0))
.offset(
x: animate ? cos(p.angle) * p.distance : 0,
y: animate ? sin(p.angle) * p.distance - 40 : 0
)
.scaleEffect(animate ? (fadeOut ? 0.1 : 1.0) : 0.1)
.opacity(fadeOut ? 0 : 1)
.position(x: cx, y: cy)
}
}
}
.onAppear {
OmiMotion.withGated(.easeOut(duration: 0.8)) {
animate = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
OmiMotion.withGated(.easeOut(duration: 0.8)) {
fadeOut = true
}
}
}
}
}