expo-orb 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +20 -0
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +247 -0
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +104 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +41 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +156 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +123 -0
- package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
- package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
- package/build/ExpoBreathingExercise.types.d.ts +48 -0
- package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
- package/build/ExpoBreathingExercise.types.js +2 -0
- package/build/ExpoBreathingExercise.types.js.map +1 -0
- package/build/ExpoBreathingExerciseModule.d.ts +16 -0
- package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseModule.js +52 -0
- package/build/ExpoBreathingExerciseModule.js.map +1 -0
- package/build/ExpoBreathingExerciseView.d.ts +4 -0
- package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseView.js +7 -0
- package/build/ExpoBreathingExerciseView.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +2 -2
- package/ios/Breathing/BreathingConfiguration.swift +57 -0
- package/ios/Breathing/BreathingExerciseView.swift +451 -0
- package/ios/Breathing/BreathingSharedState.swift +84 -0
- package/ios/Breathing/BreathingTextCue.swift +14 -0
- package/ios/Breathing/MorphingBlobView.swift +242 -0
- package/ios/Breathing/ProgressRingView.swift +27 -0
- package/ios/ExpoBreathingExerciseModule.swift +182 -0
- package/ios/ExpoBreathingExerciseView.swift +124 -0
- package/package.json +8 -5
- package/src/ExpoBreathingExercise.types.ts +50 -0
- package/src/ExpoBreathingExerciseModule.ts +67 -0
- package/src/ExpoBreathingExerciseView.tsx +11 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
public struct BreathingExerciseView: View {
|
|
4
|
+
private let config: BreathingConfiguration
|
|
5
|
+
|
|
6
|
+
public init(configuration: BreathingConfiguration = BreathingConfiguration()) {
|
|
7
|
+
self.config = configuration
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public var body: some View {
|
|
11
|
+
TimelineView(.animation) { timeline in
|
|
12
|
+
GeometryReader { geometry in
|
|
13
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
14
|
+
let elapsedTime = timeline.date.timeIntervalSinceReferenceDate
|
|
15
|
+
|
|
16
|
+
// Update all animation state
|
|
17
|
+
let animState = updateAllAnimations(at: timeline.date, pointCount: config.pointCount)
|
|
18
|
+
|
|
19
|
+
ZStack {
|
|
20
|
+
// Progress ring (behind blob)
|
|
21
|
+
if config.showProgressRing && animState.isActive {
|
|
22
|
+
ProgressRingView(
|
|
23
|
+
progress: animState.phaseProgress,
|
|
24
|
+
color: config.progressRingColor,
|
|
25
|
+
lineWidth: 4,
|
|
26
|
+
size: size * 0.95
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Base gradient background layer
|
|
31
|
+
LinearGradient(
|
|
32
|
+
colors: config.blobColors,
|
|
33
|
+
startPoint: .bottom,
|
|
34
|
+
endPoint: .top
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Base depth glows - creates depth with rotating glow effects
|
|
38
|
+
baseDepthGlows(size: size, elapsedTime: elapsedTime)
|
|
39
|
+
|
|
40
|
+
// Wavy blobs - adds organic movement with flowing blob shapes
|
|
41
|
+
if config.showWavyBlobs {
|
|
42
|
+
wavyBlob(size: size, elapsedTime: elapsedTime)
|
|
43
|
+
wavyBlobTwo(size: size, elapsedTime: elapsedTime)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Core glow effects - adds bright, energetic core glow animations
|
|
47
|
+
if config.showGlowEffects {
|
|
48
|
+
coreGlowEffects(size: size, elapsedTime: elapsedTime)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Particles - overlays floating particle effects for additional dynamism
|
|
52
|
+
if config.showParticles {
|
|
53
|
+
particleView
|
|
54
|
+
.frame(maxWidth: size, maxHeight: size)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Inner glows overlay for depth
|
|
58
|
+
.overlay {
|
|
59
|
+
realisticInnerGlows
|
|
60
|
+
}
|
|
61
|
+
// Mask with morphing blob shape
|
|
62
|
+
.mask {
|
|
63
|
+
MorphingBlobShape(
|
|
64
|
+
center: CGPoint(x: size / 2, y: size / 2),
|
|
65
|
+
baseRadius: animState.scale * 0.7 * size / 2,
|
|
66
|
+
pointCount: config.pointCount,
|
|
67
|
+
offsets: animState.wobbleOffsets
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
.frame(width: size, height: size)
|
|
71
|
+
.overlay {
|
|
72
|
+
// Text cue
|
|
73
|
+
if config.showTextCue && !animState.label.isEmpty {
|
|
74
|
+
BreathingTextCue(
|
|
75
|
+
text: animState.label,
|
|
76
|
+
color: config.textColor
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
.modifier(
|
|
81
|
+
BreathingShadowModifier(
|
|
82
|
+
colors: config.showShadow ? config.blobColors : [.clear],
|
|
83
|
+
radius: size * 0.06,
|
|
84
|
+
scale: animState.scale
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
.aspectRatio(1, contentMode: .fit)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - Visual Effects (from OrbView)
|
|
93
|
+
|
|
94
|
+
private var particleView: some View {
|
|
95
|
+
ZStack {
|
|
96
|
+
ParticlesView(
|
|
97
|
+
color: config.particleColor,
|
|
98
|
+
speedRange: 10...20,
|
|
99
|
+
sizeRange: 0.5...1,
|
|
100
|
+
particleCount: 10,
|
|
101
|
+
opacityRange: 0...0.3
|
|
102
|
+
)
|
|
103
|
+
.blur(radius: 1)
|
|
104
|
+
|
|
105
|
+
ParticlesView(
|
|
106
|
+
color: config.particleColor,
|
|
107
|
+
speedRange: 20...30,
|
|
108
|
+
sizeRange: 0.2...1,
|
|
109
|
+
particleCount: 10,
|
|
110
|
+
opacityRange: 0.3...0.8
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
.blendMode(.plusLighter)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func wavyBlob(size: CGFloat, elapsedTime: Double) -> some View {
|
|
117
|
+
let blobSpeed: Double = 20
|
|
118
|
+
|
|
119
|
+
return RotatingGlowView(
|
|
120
|
+
color: .white.opacity(0.75),
|
|
121
|
+
rotationSpeed: blobSpeed * 1.5,
|
|
122
|
+
direction: .clockwise,
|
|
123
|
+
elapsedTime: elapsedTime
|
|
124
|
+
)
|
|
125
|
+
.mask {
|
|
126
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 1.75)
|
|
127
|
+
.frame(maxWidth: size * 1.875)
|
|
128
|
+
.offset(x: 0, y: size * 0.31)
|
|
129
|
+
}
|
|
130
|
+
.blur(radius: 1)
|
|
131
|
+
.blendMode(.plusLighter)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func wavyBlobTwo(size: CGFloat, elapsedTime: Double) -> some View {
|
|
135
|
+
let blobSpeed: Double = 20
|
|
136
|
+
|
|
137
|
+
return RotatingGlowView(
|
|
138
|
+
color: .white,
|
|
139
|
+
rotationSpeed: blobSpeed * 0.75,
|
|
140
|
+
direction: .counterClockwise,
|
|
141
|
+
elapsedTime: elapsedTime
|
|
142
|
+
)
|
|
143
|
+
.mask {
|
|
144
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 2.25)
|
|
145
|
+
.frame(maxWidth: size * 1.25)
|
|
146
|
+
.rotationEffect(.degrees(90))
|
|
147
|
+
.offset(x: 0, y: size * -0.31)
|
|
148
|
+
}
|
|
149
|
+
.opacity(0.5)
|
|
150
|
+
.blur(radius: 1)
|
|
151
|
+
.blendMode(.plusLighter)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func coreGlowEffects(size: CGFloat, elapsedTime: Double) -> some View {
|
|
155
|
+
let speed: Double = 18.0
|
|
156
|
+
let glowIntensity: Double = 0.8
|
|
157
|
+
|
|
158
|
+
return ZStack {
|
|
159
|
+
RotatingGlowView(
|
|
160
|
+
color: config.glowColor,
|
|
161
|
+
rotationSpeed: speed * 1.2,
|
|
162
|
+
direction: .clockwise,
|
|
163
|
+
elapsedTime: elapsedTime
|
|
164
|
+
)
|
|
165
|
+
.blur(radius: size * 0.08)
|
|
166
|
+
.opacity(glowIntensity)
|
|
167
|
+
|
|
168
|
+
RotatingGlowView(
|
|
169
|
+
color: config.glowColor,
|
|
170
|
+
rotationSpeed: speed * 0.9,
|
|
171
|
+
direction: .clockwise,
|
|
172
|
+
elapsedTime: elapsedTime
|
|
173
|
+
)
|
|
174
|
+
.blur(radius: size * 0.06)
|
|
175
|
+
.opacity(glowIntensity)
|
|
176
|
+
.blendMode(.plusLighter)
|
|
177
|
+
}
|
|
178
|
+
.padding(size * 0.08)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func baseDepthGlows(size: CGFloat, elapsedTime: Double) -> some View {
|
|
182
|
+
let speed: Double = 18.0
|
|
183
|
+
|
|
184
|
+
return ZStack {
|
|
185
|
+
// Outer glow
|
|
186
|
+
RotatingGlowView(
|
|
187
|
+
color: config.glowColor,
|
|
188
|
+
rotationSpeed: speed * 0.75,
|
|
189
|
+
direction: .counterClockwise,
|
|
190
|
+
elapsedTime: elapsedTime
|
|
191
|
+
)
|
|
192
|
+
.padding(size * 0.03)
|
|
193
|
+
.blur(radius: size * 0.06)
|
|
194
|
+
.rotationEffect(.degrees(180))
|
|
195
|
+
.blendMode(.destinationOver)
|
|
196
|
+
|
|
197
|
+
// Outer ring
|
|
198
|
+
RotatingGlowView(
|
|
199
|
+
color: config.glowColor.opacity(0.5),
|
|
200
|
+
rotationSpeed: speed * 0.25,
|
|
201
|
+
direction: .clockwise,
|
|
202
|
+
elapsedTime: elapsedTime
|
|
203
|
+
)
|
|
204
|
+
.frame(maxWidth: size * 0.94)
|
|
205
|
+
.rotationEffect(.degrees(180))
|
|
206
|
+
.padding(8)
|
|
207
|
+
.blur(radius: size * 0.032)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private var orbOutlineColor: LinearGradient {
|
|
212
|
+
LinearGradient(
|
|
213
|
+
colors: [.white, .clear],
|
|
214
|
+
startPoint: .bottom,
|
|
215
|
+
endPoint: .top
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private var realisticInnerGlows: some View {
|
|
220
|
+
ZStack {
|
|
221
|
+
// Outer stroke with heavy blur
|
|
222
|
+
Circle()
|
|
223
|
+
.stroke(orbOutlineColor, lineWidth: 8)
|
|
224
|
+
.blur(radius: 32)
|
|
225
|
+
.blendMode(.plusLighter)
|
|
226
|
+
|
|
227
|
+
// Inner stroke with light blur
|
|
228
|
+
Circle()
|
|
229
|
+
.stroke(orbOutlineColor, lineWidth: 4)
|
|
230
|
+
.blur(radius: 12)
|
|
231
|
+
.blendMode(.plusLighter)
|
|
232
|
+
|
|
233
|
+
Circle()
|
|
234
|
+
.stroke(orbOutlineColor, lineWidth: 1)
|
|
235
|
+
.blur(radius: 4)
|
|
236
|
+
.blendMode(.plusLighter)
|
|
237
|
+
}
|
|
238
|
+
.padding(1)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private struct AnimState {
|
|
242
|
+
let scale: Double
|
|
243
|
+
let wobbleOffsets: [Double]
|
|
244
|
+
let phaseProgress: Double
|
|
245
|
+
let label: String
|
|
246
|
+
let isActive: Bool
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private func updateAllAnimations(at date: Date, pointCount: Int) -> AnimState {
|
|
250
|
+
let state = BreathingSharedState.shared
|
|
251
|
+
|
|
252
|
+
// Always update wobble animation (organic movement even when idle)
|
|
253
|
+
updateWobble(at: date, pointCount: pointCount)
|
|
254
|
+
|
|
255
|
+
// Handle breathing state
|
|
256
|
+
switch state.state {
|
|
257
|
+
case .stopped, .complete:
|
|
258
|
+
return AnimState(
|
|
259
|
+
scale: 1.0,
|
|
260
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
261
|
+
phaseProgress: 0,
|
|
262
|
+
label: "",
|
|
263
|
+
isActive: false
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
case .paused:
|
|
267
|
+
return AnimState(
|
|
268
|
+
scale: state.currentScale,
|
|
269
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
270
|
+
phaseProgress: state.phaseProgress,
|
|
271
|
+
label: state.currentLabel,
|
|
272
|
+
isActive: true
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
case .running:
|
|
276
|
+
// Update phase timing
|
|
277
|
+
updatePhaseState(at: date)
|
|
278
|
+
|
|
279
|
+
// Calculate scale and progress based on phase
|
|
280
|
+
let currentPhaseConfig = state.phases.isEmpty ? nil : state.phases[state.currentPhaseIndex]
|
|
281
|
+
let scale: Double
|
|
282
|
+
let ringProgress: Double
|
|
283
|
+
|
|
284
|
+
if let phase = currentPhaseConfig?.phase {
|
|
285
|
+
switch phase {
|
|
286
|
+
case .inhale:
|
|
287
|
+
// Ease-out cubic for smooth deceleration toward target
|
|
288
|
+
let easedProgress = easeOutCubic(state.phaseProgress)
|
|
289
|
+
scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
|
|
290
|
+
ringProgress = easedProgress // 0 -> 1
|
|
291
|
+
case .exhale:
|
|
292
|
+
let easedProgress = easeOutCubic(state.phaseProgress)
|
|
293
|
+
scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
|
|
294
|
+
ringProgress = 1.0 - easedProgress // 1 -> 0
|
|
295
|
+
case .holdIn:
|
|
296
|
+
// Hold at expanded scale, ring stays full
|
|
297
|
+
scale = state.targetScale
|
|
298
|
+
ringProgress = 1.0
|
|
299
|
+
case .holdOut:
|
|
300
|
+
// Hold at contracted scale, ring stays empty
|
|
301
|
+
scale = state.targetScale
|
|
302
|
+
ringProgress = 0.0
|
|
303
|
+
case .idle:
|
|
304
|
+
scale = state.targetScale
|
|
305
|
+
ringProgress = 0.0
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
scale = 1.0
|
|
309
|
+
ringProgress = 0.0
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
state.currentScale = scale
|
|
313
|
+
|
|
314
|
+
return AnimState(
|
|
315
|
+
scale: scale,
|
|
316
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
317
|
+
phaseProgress: ringProgress,
|
|
318
|
+
label: state.currentLabel,
|
|
319
|
+
isActive: true
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func updatePhaseState(at date: Date) {
|
|
325
|
+
let state = BreathingSharedState.shared
|
|
326
|
+
guard !state.phases.isEmpty else { return }
|
|
327
|
+
|
|
328
|
+
let currentPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
329
|
+
let elapsed = date.timeIntervalSince(state.phaseStartTime)
|
|
330
|
+
let duration = currentPhaseConfig.duration
|
|
331
|
+
|
|
332
|
+
// Calculate progress through current phase
|
|
333
|
+
state.phaseProgress = min(1.0, elapsed / duration)
|
|
334
|
+
|
|
335
|
+
// Check if phase is complete
|
|
336
|
+
if elapsed >= duration {
|
|
337
|
+
// Move to next phase
|
|
338
|
+
state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.count
|
|
339
|
+
|
|
340
|
+
// Check if we completed a cycle
|
|
341
|
+
if state.currentPhaseIndex == 0 {
|
|
342
|
+
state.currentCycle += 1
|
|
343
|
+
|
|
344
|
+
// Check if exercise is complete
|
|
345
|
+
if let totalCycles = state.totalCycles, state.currentCycle >= totalCycles {
|
|
346
|
+
state.state = .complete
|
|
347
|
+
state.totalDuration = date.timeIntervalSince(state.exerciseStartTime)
|
|
348
|
+
state.onExerciseComplete?(state.currentCycle, state.totalDuration)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Start new phase
|
|
354
|
+
state.phaseStartTime = date
|
|
355
|
+
let newPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
356
|
+
state.currentPhase = newPhaseConfig.phase
|
|
357
|
+
state.currentLabel = newPhaseConfig.label
|
|
358
|
+
state.startScale = state.currentScale // Remember where we're starting from
|
|
359
|
+
state.targetScale = newPhaseConfig.targetScale
|
|
360
|
+
state.phaseProgress = 0
|
|
361
|
+
|
|
362
|
+
// Update wobble intensity based on phase
|
|
363
|
+
switch newPhaseConfig.phase {
|
|
364
|
+
case .inhale, .exhale:
|
|
365
|
+
state.wobbleIntensity = 1.0
|
|
366
|
+
case .holdIn, .holdOut:
|
|
367
|
+
state.wobbleIntensity = 0.3
|
|
368
|
+
case .idle:
|
|
369
|
+
state.wobbleIntensity = 0.5
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Fire phase change callback
|
|
373
|
+
state.onPhaseChange?(
|
|
374
|
+
newPhaseConfig.phase,
|
|
375
|
+
newPhaseConfig.label,
|
|
376
|
+
state.currentPhaseIndex,
|
|
377
|
+
state.currentCycle
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private func updateWobble(at date: Date, pointCount: Int) {
|
|
383
|
+
let state = BreathingSharedState.shared
|
|
384
|
+
|
|
385
|
+
// Initialize arrays if needed
|
|
386
|
+
if state.wobbleOffsets.count != pointCount {
|
|
387
|
+
state.wobbleOffsets = (0..<pointCount).map { _ in 0.0 }
|
|
388
|
+
state.wobbleTargets = (0..<pointCount).map { _ in 0.0 }
|
|
389
|
+
state.lastWobbleUpdate = date
|
|
390
|
+
state.lastWobbleTargetUpdate = date
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let now = date
|
|
394
|
+
let dt = now.timeIntervalSince(state.lastWobbleUpdate)
|
|
395
|
+
state.lastWobbleUpdate = now
|
|
396
|
+
|
|
397
|
+
// Generate new random targets periodically (slower, more organic)
|
|
398
|
+
let timeSinceTargetUpdate = now.timeIntervalSince(state.lastWobbleTargetUpdate)
|
|
399
|
+
if timeSinceTargetUpdate > 1.8 {
|
|
400
|
+
state.lastWobbleTargetUpdate = now
|
|
401
|
+
|
|
402
|
+
let intensity = state.wobbleIntensity * config.wobbleIntensity
|
|
403
|
+
let maxOffset = 0.18 * intensity
|
|
404
|
+
|
|
405
|
+
state.wobbleTargets = (0..<pointCount).map { i in
|
|
406
|
+
let phase = Double(i) / Double(pointCount) * 2.0 * .pi
|
|
407
|
+
let wave1 = sin(phase * 2 + Double.random(in: 0...1)) * 0.6
|
|
408
|
+
let wave2 = cos(phase * 3 + Double.random(in: 0...1)) * 0.4
|
|
409
|
+
let baseOffset = Double.random(in: -maxOffset...maxOffset)
|
|
410
|
+
return baseOffset * (1.0 + wave1 + wave2) * 0.5
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Smoothly interpolate current offsets toward targets
|
|
415
|
+
let interpolationSpeed = 1.8
|
|
416
|
+
let factor = min(1.0, dt * interpolationSpeed)
|
|
417
|
+
|
|
418
|
+
for i in 0..<pointCount {
|
|
419
|
+
let current = state.wobbleOffsets[i]
|
|
420
|
+
let target = state.wobbleTargets[i]
|
|
421
|
+
state.wobbleOffsets[i] = current + (target - current) * factor
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
struct BreathingShadowModifier: ViewModifier {
|
|
427
|
+
let colors: [Color]
|
|
428
|
+
let radius: CGFloat
|
|
429
|
+
let scale: Double
|
|
430
|
+
|
|
431
|
+
func body(content: Content) -> some View {
|
|
432
|
+
content
|
|
433
|
+
.shadow(color: colors.first?.opacity(0.4) ?? .clear, radius: radius * scale, x: 0, y: radius * 0.3 * scale)
|
|
434
|
+
.shadow(color: colors.first?.opacity(0.2) ?? .clear, radius: radius * 2 * scale, x: 0, y: radius * 0.5 * scale)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Easing functions
|
|
439
|
+
private func easeOutCubic(_ t: Double) -> Double {
|
|
440
|
+
let t1 = t - 1
|
|
441
|
+
return t1 * t1 * t1 + 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private func easeInOutCubic(_ t: Double) -> Double {
|
|
445
|
+
if t < 0.5 {
|
|
446
|
+
return 4 * t * t * t
|
|
447
|
+
} else {
|
|
448
|
+
let t1 = -2 * t + 2
|
|
449
|
+
return 1 - (t1 * t1 * t1) / 2
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum BreathPhase: String {
|
|
4
|
+
case inhale
|
|
5
|
+
case holdIn
|
|
6
|
+
case exhale
|
|
7
|
+
case holdOut
|
|
8
|
+
case idle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public enum BreathingExerciseState {
|
|
12
|
+
case stopped
|
|
13
|
+
case running
|
|
14
|
+
case paused
|
|
15
|
+
case complete
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public final class BreathingSharedState {
|
|
19
|
+
public static let shared = BreathingSharedState()
|
|
20
|
+
|
|
21
|
+
// Pattern configuration
|
|
22
|
+
var phases: [BreathPhaseConfig] = []
|
|
23
|
+
var totalCycles: Int? = nil // nil = infinite
|
|
24
|
+
|
|
25
|
+
// Current state
|
|
26
|
+
var state: BreathingExerciseState = .stopped
|
|
27
|
+
var currentPhaseIndex: Int = 0
|
|
28
|
+
var currentCycle: Int = 0
|
|
29
|
+
var phaseStartTime: Date = Date()
|
|
30
|
+
var pauseTime: Date? = nil
|
|
31
|
+
|
|
32
|
+
// Animation values (read by TimelineView each frame)
|
|
33
|
+
var currentScale: Double = 1.0
|
|
34
|
+
var targetScale: Double = 1.0
|
|
35
|
+
var startScale: Double = 1.0 // Scale at start of current phase
|
|
36
|
+
var lastScaleUpdate: Date = Date()
|
|
37
|
+
var currentPhase: BreathPhase = .idle
|
|
38
|
+
var currentLabel: String = ""
|
|
39
|
+
var phaseProgress: Double = 0.0 // 0-1 progress through current phase
|
|
40
|
+
|
|
41
|
+
// Wobble animation
|
|
42
|
+
var wobbleIntensity: Double = 1.0
|
|
43
|
+
var wobbleOffsets: [Double] = []
|
|
44
|
+
var wobbleTargets: [Double] = []
|
|
45
|
+
var lastWobbleUpdate: Date = Date()
|
|
46
|
+
var lastWobbleTargetUpdate: Date = Date()
|
|
47
|
+
|
|
48
|
+
// Exercise tracking
|
|
49
|
+
var exerciseStartTime: Date = Date()
|
|
50
|
+
var totalDuration: Double = 0.0
|
|
51
|
+
|
|
52
|
+
// Callbacks
|
|
53
|
+
var onPhaseChange: ((BreathPhase, String, Int, Int) -> Void)? = nil
|
|
54
|
+
var onExerciseComplete: ((Int, Double) -> Void)? = nil
|
|
55
|
+
|
|
56
|
+
private init() {}
|
|
57
|
+
|
|
58
|
+
func reset() {
|
|
59
|
+
state = .stopped
|
|
60
|
+
currentPhaseIndex = 0
|
|
61
|
+
currentCycle = 0
|
|
62
|
+
currentScale = 1.0
|
|
63
|
+
targetScale = 1.0
|
|
64
|
+
currentPhase = .idle
|
|
65
|
+
currentLabel = ""
|
|
66
|
+
phaseProgress = 0.0
|
|
67
|
+
wobbleIntensity = 1.0
|
|
68
|
+
pauseTime = nil
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public struct BreathPhaseConfig {
|
|
73
|
+
let phase: BreathPhase
|
|
74
|
+
let duration: Double // seconds
|
|
75
|
+
let targetScale: Double
|
|
76
|
+
let label: String
|
|
77
|
+
|
|
78
|
+
init(phase: BreathPhase, duration: Double, targetScale: Double, label: String) {
|
|
79
|
+
self.phase = phase
|
|
80
|
+
self.duration = duration
|
|
81
|
+
self.targetScale = targetScale
|
|
82
|
+
self.label = label
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct BreathingTextCue: View {
|
|
4
|
+
let text: String
|
|
5
|
+
let color: Color
|
|
6
|
+
|
|
7
|
+
var body: some View {
|
|
8
|
+
Text(text)
|
|
9
|
+
.font(.system(size: 24, weight: .medium, design: .rounded))
|
|
10
|
+
.foregroundColor(color)
|
|
11
|
+
.shadow(color: color.opacity(0.5), radius: 4, x: 0, y: 2)
|
|
12
|
+
.animation(.easeInOut(duration: 0.3), value: text)
|
|
13
|
+
}
|
|
14
|
+
}
|