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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +20 -0
  3. package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +247 -0
  4. package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +104 -0
  5. package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +41 -0
  6. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +156 -0
  7. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +123 -0
  8. package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
  9. package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
  10. package/build/ExpoBreathingExercise.types.d.ts +48 -0
  11. package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
  12. package/build/ExpoBreathingExercise.types.js +2 -0
  13. package/build/ExpoBreathingExercise.types.js.map +1 -0
  14. package/build/ExpoBreathingExerciseModule.d.ts +16 -0
  15. package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
  16. package/build/ExpoBreathingExerciseModule.js +52 -0
  17. package/build/ExpoBreathingExerciseModule.js.map +1 -0
  18. package/build/ExpoBreathingExerciseView.d.ts +4 -0
  19. package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
  20. package/build/ExpoBreathingExerciseView.js +7 -0
  21. package/build/ExpoBreathingExerciseView.js.map +1 -0
  22. package/build/index.d.ts +3 -0
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +3 -0
  25. package/build/index.js.map +1 -1
  26. package/expo-module.config.json +2 -2
  27. package/ios/Breathing/BreathingConfiguration.swift +57 -0
  28. package/ios/Breathing/BreathingExerciseView.swift +451 -0
  29. package/ios/Breathing/BreathingSharedState.swift +84 -0
  30. package/ios/Breathing/BreathingTextCue.swift +14 -0
  31. package/ios/Breathing/MorphingBlobView.swift +242 -0
  32. package/ios/Breathing/ProgressRingView.swift +27 -0
  33. package/ios/ExpoBreathingExerciseModule.swift +182 -0
  34. package/ios/ExpoBreathingExerciseView.swift +124 -0
  35. package/package.json +8 -5
  36. package/src/ExpoBreathingExercise.types.ts +50 -0
  37. package/src/ExpoBreathingExerciseModule.ts +67 -0
  38. package/src/ExpoBreathingExerciseView.tsx +11 -0
  39. 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
+ }