expo-orb 0.1.0 → 0.2.2

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 (41) hide show
  1. package/README.md +209 -1
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +25 -0
  4. package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +610 -0
  5. package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +108 -0
  6. package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +51 -0
  7. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +177 -0
  8. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +144 -0
  9. package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
  10. package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
  11. package/build/ExpoBreathingExercise.types.d.ts +48 -0
  12. package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
  13. package/build/ExpoBreathingExercise.types.js +2 -0
  14. package/build/ExpoBreathingExercise.types.js.map +1 -0
  15. package/build/ExpoBreathingExerciseModule.d.ts +16 -0
  16. package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
  17. package/build/ExpoBreathingExerciseModule.js +52 -0
  18. package/build/ExpoBreathingExerciseModule.js.map +1 -0
  19. package/build/ExpoBreathingExerciseView.d.ts +4 -0
  20. package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
  21. package/build/ExpoBreathingExerciseView.js +7 -0
  22. package/build/ExpoBreathingExerciseView.js.map +1 -0
  23. package/build/index.d.ts +3 -0
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +3 -0
  26. package/build/index.js.map +1 -1
  27. package/expo-module.config.json +2 -2
  28. package/ios/Breathing/BreathingConfiguration.swift +57 -0
  29. package/ios/Breathing/BreathingExerciseView.swift +451 -0
  30. package/ios/Breathing/BreathingParticlesView.swift +81 -0
  31. package/ios/Breathing/BreathingSharedState.swift +84 -0
  32. package/ios/Breathing/BreathingTextCue.swift +14 -0
  33. package/ios/Breathing/MorphingBlobView.swift +242 -0
  34. package/ios/Breathing/ProgressRingView.swift +27 -0
  35. package/ios/ExpoBreathingExerciseModule.swift +182 -0
  36. package/ios/ExpoBreathingExerciseView.swift +124 -0
  37. package/package.json +8 -5
  38. package/src/ExpoBreathingExercise.types.ts +50 -0
  39. package/src/ExpoBreathingExerciseModule.ts +67 -0
  40. package/src/ExpoBreathingExerciseView.tsx +11 -0
  41. package/src/index.ts +11 -0
@@ -0,0 +1,610 @@
1
+ package expo.modules.breathing
2
+
3
+ import androidx.compose.foundation.Canvas
4
+ import androidx.compose.foundation.background
5
+ import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.aspectRatio
7
+ import androidx.compose.foundation.layout.fillMaxSize
8
+ import androidx.compose.foundation.layout.offset
9
+ import androidx.compose.foundation.layout.padding
10
+ import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.LaunchedEffect
12
+ import androidx.compose.runtime.getValue
13
+ import androidx.compose.runtime.mutableLongStateOf
14
+ import androidx.compose.runtime.mutableStateOf
15
+ import androidx.compose.runtime.remember
16
+ import androidx.compose.runtime.setValue
17
+ import androidx.compose.runtime.withFrameNanos
18
+ import androidx.compose.ui.Alignment
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.draw.blur
21
+ import androidx.compose.ui.draw.clip
22
+ import androidx.compose.ui.draw.drawBehind
23
+ import androidx.compose.ui.draw.scale
24
+ import androidx.compose.ui.geometry.Offset
25
+ import androidx.compose.ui.graphics.Brush
26
+ import androidx.compose.ui.graphics.Color
27
+ import androidx.compose.ui.graphics.Outline
28
+ import androidx.compose.ui.graphics.Path
29
+ import androidx.compose.ui.graphics.Shape
30
+ import androidx.compose.ui.graphics.graphicsLayer
31
+ import androidx.compose.ui.unit.Density
32
+ import androidx.compose.ui.unit.LayoutDirection
33
+ import androidx.compose.ui.unit.dp
34
+ import expo.modules.orb.ParticlesView
35
+ import expo.modules.orb.RotationDirection
36
+ import expo.modules.orb.SimpleRotatingGlow
37
+ import expo.modules.orb.WavyBlobView
38
+ import kotlin.math.PI
39
+ import kotlin.math.cos
40
+ import kotlin.math.min
41
+ import kotlin.math.pow
42
+ import kotlin.math.sin
43
+ import kotlin.math.tan
44
+ import kotlin.random.Random
45
+
46
+ @Composable
47
+ fun BreathingExerciseView(
48
+ config: BreathingConfiguration,
49
+ modifier: Modifier = Modifier
50
+ ) {
51
+ // Frame-based animation state
52
+ var frameTime by remember { mutableLongStateOf(System.nanoTime()) }
53
+ var elapsedTime by remember { mutableStateOf(0.0) }
54
+
55
+ // Continuous animation loop
56
+ LaunchedEffect(Unit) {
57
+ val startTime = System.nanoTime()
58
+ while (true) {
59
+ withFrameNanos { currentTime ->
60
+ frameTime = currentTime
61
+ elapsedTime = (currentTime - startTime) / 1_000_000_000.0
62
+ }
63
+ }
64
+ }
65
+
66
+ // Initialize wobble offsets if needed
67
+ val state = BreathingSharedState
68
+ if (state.wobbleOffsets.size != config.pointCount) {
69
+ state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
70
+ state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
71
+ }
72
+
73
+ // Update animation state
74
+ val animationState = updateAnimationState(frameTime, config)
75
+
76
+ // Create blob shape for clipping
77
+ val blobShape = remember(animationState.scale, animationState.wobbleOffsets, config.pointCount) {
78
+ MorphingBlobShape(
79
+ baseRadius = animationState.scale.toFloat() * 0.7f,
80
+ pointCount = config.pointCount,
81
+ offsets = animationState.wobbleOffsets
82
+ )
83
+ }
84
+
85
+ Box(
86
+ modifier = modifier.aspectRatio(1f),
87
+ contentAlignment = Alignment.Center
88
+ ) {
89
+ // Progress ring (behind blob)
90
+ if (config.showProgressRing && animationState.isActive) {
91
+ ProgressRingView(
92
+ progress = animationState.phaseProgress,
93
+ color = config.progressRingColor,
94
+ lineWidth = 4f,
95
+ modifier = Modifier.fillMaxSize(0.95f)
96
+ )
97
+ }
98
+
99
+ // Main blob with all effects
100
+ Box(
101
+ modifier = Modifier
102
+ .fillMaxSize()
103
+ .graphicsLayer { clip = false }
104
+ ) {
105
+ // Shadow
106
+ if (config.showShadow) {
107
+ BreathingShadow(
108
+ colors = config.blobColors,
109
+ scale = animationState.scale.toFloat(),
110
+ modifier = Modifier
111
+ .fillMaxSize()
112
+ .graphicsLayer { clip = false }
113
+ )
114
+ }
115
+
116
+ // Main blob content clipped to morphing shape
117
+ Box(
118
+ modifier = Modifier
119
+ .fillMaxSize()
120
+ .clip(blobShape)
121
+ ) {
122
+ // Background gradient
123
+ Box(
124
+ modifier = Modifier
125
+ .fillMaxSize()
126
+ .background(
127
+ brush = Brush.verticalGradient(
128
+ colors = config.blobColors.reversed()
129
+ )
130
+ )
131
+ )
132
+
133
+ // Base depth glows
134
+ BaseDepthGlows(
135
+ glowColor = config.glowColor,
136
+ elapsedTime = elapsedTime,
137
+ modifier = Modifier.fillMaxSize()
138
+ )
139
+
140
+ // Wavy blobs
141
+ if (config.showWavyBlobs) {
142
+ WavyBlobLayer(
143
+ elapsedTime = elapsedTime,
144
+ modifier = Modifier.fillMaxSize()
145
+ )
146
+ }
147
+
148
+ // Core glow effects
149
+ if (config.showGlowEffects) {
150
+ CoreGlowEffects(
151
+ glowColor = config.glowColor,
152
+ elapsedTime = elapsedTime,
153
+ modifier = Modifier
154
+ .fillMaxSize()
155
+ .padding(8.dp)
156
+ )
157
+ }
158
+
159
+ // Particles
160
+ if (config.showParticles) {
161
+ ParticlesView(
162
+ color = config.particleColor,
163
+ particleCount = 15,
164
+ speedRange = 10f..20f,
165
+ sizeRange = 1f..3f,
166
+ opacityRange = 0.1f..0.4f,
167
+ elapsedTime = elapsedTime,
168
+ modifier = Modifier
169
+ .fillMaxSize()
170
+ .blur(1.dp)
171
+ )
172
+ ParticlesView(
173
+ color = config.particleColor,
174
+ particleCount = 10,
175
+ speedRange = 20f..35f,
176
+ sizeRange = 0.5f..2f,
177
+ opacityRange = 0.3f..0.7f,
178
+ elapsedTime = elapsedTime,
179
+ modifier = Modifier.fillMaxSize()
180
+ )
181
+ }
182
+
183
+ // Inner glow overlay
184
+ InnerGlowOverlay(
185
+ modifier = Modifier.fillMaxSize()
186
+ )
187
+ }
188
+ }
189
+
190
+ // Text cue (on top)
191
+ if (config.showTextCue && animationState.label.isNotEmpty()) {
192
+ BreathingTextCue(
193
+ text = animationState.label,
194
+ color = config.textColor
195
+ )
196
+ }
197
+ }
198
+ }
199
+
200
+ // Custom shape for clipping
201
+ private class MorphingBlobShape(
202
+ private val baseRadius: Float,
203
+ private val pointCount: Int,
204
+ private val offsets: List<Double>
205
+ ) : Shape {
206
+ override fun createOutline(
207
+ size: androidx.compose.ui.geometry.Size,
208
+ layoutDirection: LayoutDirection,
209
+ density: Density
210
+ ): Outline {
211
+ val path = Path()
212
+ if (pointCount < 3) return Outline.Generic(path)
213
+
214
+ val minDim = min(size.width, size.height)
215
+ val center = Offset(size.width / 2f, size.height / 2f)
216
+ val effectiveRadius = baseRadius * minDim / 2f
217
+
218
+ // Generate points around the circle with offsets
219
+ val points = mutableListOf<Offset>()
220
+ for (i in 0 until pointCount) {
221
+ val angle = (i.toDouble() / pointCount) * 2 * PI - PI / 2
222
+ val offset = if (i < offsets.size) offsets[i] else 0.0
223
+ val radius = effectiveRadius * (1.0 + offset).toFloat()
224
+
225
+ points.add(
226
+ Offset(
227
+ x = center.x + (cos(angle) * radius).toFloat(),
228
+ y = center.y + (sin(angle) * radius).toFloat()
229
+ )
230
+ )
231
+ }
232
+
233
+ // Tangent coefficient for smooth cubic bezier curves
234
+ val tangentCoeff = ((4.0 / 3.0) * tan(PI / (2.0 * pointCount))).toFloat()
235
+
236
+ // Start at first point
237
+ path.moveTo(points[0].x, points[0].y)
238
+
239
+ // Draw cubic bezier curves between each pair of points
240
+ for (i in 0 until pointCount) {
241
+ val current = points[i]
242
+ val next = points[(i + 1) % pointCount]
243
+
244
+ val currentAngle = (i.toDouble() / pointCount) * 2 * PI - PI / 2
245
+ val nextAngle = (((i + 1) % pointCount).toDouble() / pointCount) * 2 * PI - PI / 2
246
+
247
+ val currentOffset = if (i < offsets.size) offsets[i] else 0.0
248
+ val currentRadius = effectiveRadius * (1.0 + currentOffset).toFloat()
249
+ val currentTangentLength = currentRadius * tangentCoeff
250
+
251
+ val nextOffset = if ((i + 1) % pointCount < offsets.size) offsets[(i + 1) % pointCount] else 0.0
252
+ val nextRadius = effectiveRadius * (1.0 + nextOffset).toFloat()
253
+ val nextTangentLength = nextRadius * tangentCoeff
254
+
255
+ val control1 = Offset(
256
+ x = current.x + (cos(currentAngle + PI / 2) * currentTangentLength).toFloat(),
257
+ y = current.y + (sin(currentAngle + PI / 2) * currentTangentLength).toFloat()
258
+ )
259
+
260
+ val control2 = Offset(
261
+ x = next.x + (cos(nextAngle - PI / 2) * nextTangentLength).toFloat(),
262
+ y = next.y + (sin(nextAngle - PI / 2) * nextTangentLength).toFloat()
263
+ )
264
+
265
+ path.cubicTo(
266
+ control1.x, control1.y,
267
+ control2.x, control2.y,
268
+ next.x, next.y
269
+ )
270
+ }
271
+
272
+ path.close()
273
+ return Outline.Generic(path)
274
+ }
275
+ }
276
+
277
+ private data class AnimationState(
278
+ val scale: Double,
279
+ val wobbleOffsets: List<Double>,
280
+ val phaseProgress: Double,
281
+ val label: String,
282
+ val isActive: Boolean
283
+ )
284
+
285
+ @Composable
286
+ private fun BaseDepthGlows(
287
+ glowColor: Color,
288
+ elapsedTime: Double,
289
+ modifier: Modifier = Modifier
290
+ ) {
291
+ val speed = 18.0
292
+ Box(modifier = modifier) {
293
+ SimpleRotatingGlow(
294
+ color = glowColor,
295
+ rotationSpeed = speed * 0.75,
296
+ direction = RotationDirection.COUNTER_CLOCKWISE,
297
+ elapsedTime = elapsedTime,
298
+ alpha = 0.5f,
299
+ modifier = Modifier
300
+ .fillMaxSize()
301
+ .padding(4.dp)
302
+ .blur(8.dp)
303
+ )
304
+ SimpleRotatingGlow(
305
+ color = glowColor.copy(alpha = 0.5f),
306
+ rotationSpeed = speed * 0.25,
307
+ direction = RotationDirection.CLOCKWISE,
308
+ elapsedTime = elapsedTime,
309
+ alpha = 0.3f,
310
+ modifier = Modifier
311
+ .fillMaxSize()
312
+ .padding(8.dp)
313
+ .blur(4.dp)
314
+ )
315
+ }
316
+ }
317
+
318
+ @Composable
319
+ private fun CoreGlowEffects(
320
+ glowColor: Color,
321
+ elapsedTime: Double,
322
+ modifier: Modifier = Modifier
323
+ ) {
324
+ val speed = 18.0
325
+ val glowIntensity = 0.8
326
+ Box(modifier = modifier) {
327
+ SimpleRotatingGlow(
328
+ color = glowColor,
329
+ rotationSpeed = speed * 1.2,
330
+ direction = RotationDirection.CLOCKWISE,
331
+ elapsedTime = elapsedTime,
332
+ alpha = (glowIntensity * 0.8).toFloat(),
333
+ modifier = Modifier
334
+ .fillMaxSize()
335
+ .blur(12.dp)
336
+ )
337
+ SimpleRotatingGlow(
338
+ color = glowColor,
339
+ rotationSpeed = speed * 0.9,
340
+ direction = RotationDirection.CLOCKWISE,
341
+ elapsedTime = elapsedTime,
342
+ alpha = (glowIntensity * 0.6).toFloat(),
343
+ modifier = Modifier
344
+ .fillMaxSize()
345
+ .blur(8.dp)
346
+ )
347
+ }
348
+ }
349
+
350
+ @Composable
351
+ private fun WavyBlobLayer(
352
+ elapsedTime: Double,
353
+ modifier: Modifier = Modifier
354
+ ) {
355
+ val blobSpeed = 20.0
356
+ Box(modifier = modifier) {
357
+ WavyBlobView(
358
+ color = Color.White.copy(alpha = 0.4f),
359
+ loopDuration = 60.0 / blobSpeed * 1.75,
360
+ elapsedTime = elapsedTime,
361
+ modifier = Modifier
362
+ .fillMaxSize()
363
+ .scale(1.5f)
364
+ .offset(y = 40.dp)
365
+ .blur(2.dp)
366
+ )
367
+ WavyBlobView(
368
+ color = Color.White.copy(alpha = 0.25f),
369
+ loopDuration = 60.0 / blobSpeed * 2.25,
370
+ elapsedTime = elapsedTime,
371
+ modifier = Modifier
372
+ .fillMaxSize()
373
+ .scale(1.2f)
374
+ .offset(y = (-40).dp)
375
+ .graphicsLayer { rotationZ = 90f }
376
+ .blur(2.dp)
377
+ )
378
+ }
379
+ }
380
+
381
+ @Composable
382
+ private fun InnerGlowOverlay(modifier: Modifier = Modifier) {
383
+ Canvas(modifier = modifier) {
384
+ val center = Offset(size.width / 2f, size.height / 2f)
385
+ val radius = min(size.width, size.height) / 2f
386
+
387
+ drawCircle(
388
+ brush = Brush.verticalGradient(
389
+ colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.15f)),
390
+ ),
391
+ radius = radius,
392
+ center = center
393
+ )
394
+ }
395
+ }
396
+
397
+ @Composable
398
+ private fun BreathingShadow(
399
+ colors: List<Color>,
400
+ scale: Float,
401
+ modifier: Modifier = Modifier
402
+ ) {
403
+ Box(modifier = modifier) {
404
+ // Soft outer shadow
405
+ Canvas(
406
+ modifier = Modifier
407
+ .fillMaxSize()
408
+ .offset(y = 8.dp)
409
+ .blur(24.dp)
410
+ .graphicsLayer { alpha = 0.3f }
411
+ ) {
412
+ drawCircle(
413
+ brush = Brush.verticalGradient(colors.reversed()),
414
+ radius = size.minDimension / 2f * scale * 0.7f
415
+ )
416
+ }
417
+ // Closer shadow
418
+ Canvas(
419
+ modifier = Modifier
420
+ .fillMaxSize()
421
+ .offset(y = 4.dp)
422
+ .blur(12.dp)
423
+ .graphicsLayer { alpha = 0.5f }
424
+ ) {
425
+ drawCircle(
426
+ brush = Brush.verticalGradient(colors.reversed()),
427
+ radius = size.minDimension / 2f * scale * 0.7f
428
+ )
429
+ }
430
+ }
431
+ }
432
+
433
+ private fun easeOutCubic(t: Double): Double {
434
+ val t1 = t - 1
435
+ return t1 * t1 * t1 + 1
436
+ }
437
+
438
+ private fun updateAnimationState(
439
+ frameTime: Long,
440
+ config: BreathingConfiguration
441
+ ): AnimationState {
442
+ val state = BreathingSharedState
443
+
444
+ // Always update wobble animation
445
+ updateWobble(frameTime, config)
446
+
447
+ return when (state.state) {
448
+ BreathingExerciseState.STOPPED, BreathingExerciseState.COMPLETE -> {
449
+ AnimationState(
450
+ scale = 1.0,
451
+ wobbleOffsets = state.wobbleOffsets.toList(),
452
+ phaseProgress = 0.0,
453
+ label = "",
454
+ isActive = false
455
+ )
456
+ }
457
+
458
+ BreathingExerciseState.PAUSED -> {
459
+ AnimationState(
460
+ scale = state.currentScale,
461
+ wobbleOffsets = state.wobbleOffsets.toList(),
462
+ phaseProgress = state.phaseProgress,
463
+ label = state.currentLabel,
464
+ isActive = true
465
+ )
466
+ }
467
+
468
+ BreathingExerciseState.RUNNING -> {
469
+ // Update phase timing
470
+ updatePhaseState(frameTime)
471
+
472
+ // Calculate scale and progress based on phase
473
+ val currentPhaseConfig = if (state.phases.isEmpty()) null else state.phases[state.currentPhaseIndex]
474
+ val scale: Double
475
+ val ringProgress: Double
476
+
477
+ if (currentPhaseConfig != null) {
478
+ when (currentPhaseConfig.phase) {
479
+ BreathPhase.INHALE -> {
480
+ val easedProgress = easeOutCubic(state.phaseProgress)
481
+ scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
482
+ ringProgress = easedProgress
483
+ }
484
+ BreathPhase.EXHALE -> {
485
+ val easedProgress = easeOutCubic(state.phaseProgress)
486
+ scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
487
+ ringProgress = 1.0 - easedProgress
488
+ }
489
+ BreathPhase.HOLD_IN -> {
490
+ scale = state.targetScale
491
+ ringProgress = 1.0
492
+ }
493
+ BreathPhase.HOLD_OUT -> {
494
+ scale = state.targetScale
495
+ ringProgress = 0.0
496
+ }
497
+ BreathPhase.IDLE -> {
498
+ scale = state.targetScale
499
+ ringProgress = 0.0
500
+ }
501
+ }
502
+ } else {
503
+ scale = 1.0
504
+ ringProgress = 0.0
505
+ }
506
+
507
+ state.currentScale = scale
508
+
509
+ AnimationState(
510
+ scale = scale,
511
+ wobbleOffsets = state.wobbleOffsets.toList(),
512
+ phaseProgress = ringProgress,
513
+ label = state.currentLabel,
514
+ isActive = true
515
+ )
516
+ }
517
+ }
518
+ }
519
+
520
+ private fun updatePhaseState(frameTime: Long) {
521
+ val state = BreathingSharedState
522
+ if (state.phases.isEmpty()) return
523
+
524
+ val currentPhaseConfig = state.phases[state.currentPhaseIndex]
525
+ val elapsed = (frameTime - state.phaseStartTime) / 1_000_000_000.0
526
+ val duration = currentPhaseConfig.duration
527
+
528
+ // Calculate progress through current phase
529
+ state.phaseProgress = min(1.0, elapsed / duration)
530
+
531
+ // Check if phase is complete
532
+ if (elapsed >= duration) {
533
+ // Move to next phase
534
+ state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.size
535
+
536
+ // Check if we completed a cycle
537
+ if (state.currentPhaseIndex == 0) {
538
+ state.currentCycle += 1
539
+
540
+ // Check if exercise is complete
541
+ val totalCycles = state.totalCycles
542
+ if (totalCycles != null && state.currentCycle >= totalCycles) {
543
+ state.state = BreathingExerciseState.COMPLETE
544
+ state.totalDuration = (frameTime - state.exerciseStartTime) / 1_000_000_000.0
545
+ state.onExerciseComplete?.invoke(state.currentCycle, state.totalDuration)
546
+ return
547
+ }
548
+ }
549
+
550
+ // Start new phase
551
+ state.phaseStartTime = frameTime
552
+ val newPhaseConfig = state.phases[state.currentPhaseIndex]
553
+ state.currentPhase = newPhaseConfig.phase
554
+ state.currentLabel = newPhaseConfig.label
555
+ state.startScale = state.currentScale
556
+ state.targetScale = newPhaseConfig.targetScale
557
+ state.phaseProgress = 0.0
558
+
559
+ // Update wobble intensity based on phase
560
+ state.wobbleIntensity = when (newPhaseConfig.phase) {
561
+ BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
562
+ BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
563
+ BreathPhase.IDLE -> 0.5
564
+ }
565
+
566
+ // Fire phase change callback
567
+ state.onPhaseChange?.invoke(
568
+ newPhaseConfig.phase,
569
+ newPhaseConfig.label,
570
+ state.currentPhaseIndex,
571
+ state.currentCycle
572
+ )
573
+ }
574
+ }
575
+
576
+ private fun updateWobble(frameTime: Long, config: BreathingConfiguration) {
577
+ val state = BreathingSharedState
578
+
579
+ // Ensure arrays are sized correctly
580
+ if (state.wobbleOffsets.size != config.pointCount) {
581
+ state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
582
+ state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
583
+ }
584
+
585
+ // Check if we need new random targets (slower, more organic)
586
+ val timeSinceLastTarget = (frameTime - state.lastWobbleTargetUpdate) / 1_000_000_000.0
587
+ if (timeSinceLastTarget > 1.8 || state.wobbleTargets.all { it == 0.0 }) {
588
+ state.lastWobbleTargetUpdate = frameTime
589
+ val intensity = state.wobbleIntensity * config.wobbleIntensity
590
+ val maxOffset = 0.18 * intensity
591
+
592
+ for (i in 0 until config.pointCount) {
593
+ val phase = i.toDouble() / config.pointCount * 2.0 * PI
594
+ val wave1 = sin(phase * 2 + Random.nextDouble()) * 0.6
595
+ val wave2 = cos(phase * 3 + Random.nextDouble()) * 0.4
596
+ val baseOffset = Random.nextDouble(-maxOffset, maxOffset)
597
+ state.wobbleTargets[i] = baseOffset * (1.0 + wave1 + wave2) * 0.5
598
+ }
599
+ }
600
+
601
+ // Smoothly interpolate offsets toward targets
602
+ val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
603
+ state.lastWobbleUpdate = frameTime
604
+ val interpolationSpeed = 1.8
605
+ val factor = min(1.0, dt * interpolationSpeed)
606
+
607
+ for (i in 0 until min(state.wobbleOffsets.size, state.wobbleTargets.size)) {
608
+ state.wobbleOffsets[i] = state.wobbleOffsets[i] + (state.wobbleTargets[i] - state.wobbleOffsets[i]) * factor
609
+ }
610
+ }
@@ -0,0 +1,108 @@
1
+ package expo.modules.breathing
2
+
3
+ enum class BreathPhase(val value: String) {
4
+ INHALE("inhale"),
5
+ HOLD_IN("holdIn"),
6
+ EXHALE("exhale"),
7
+ HOLD_OUT("holdOut"),
8
+ IDLE("idle")
9
+ }
10
+
11
+ enum class BreathingExerciseState {
12
+ STOPPED,
13
+ RUNNING,
14
+ PAUSED,
15
+ COMPLETE
16
+ }
17
+
18
+ data class BreathPhaseConfig(
19
+ val phase: BreathPhase,
20
+ val duration: Double, // seconds
21
+ val targetScale: Double,
22
+ val label: String
23
+ )
24
+
25
+ object BreathingSharedState {
26
+ // Pattern configuration
27
+ @Volatile
28
+ var phases: List<BreathPhaseConfig> = emptyList()
29
+
30
+ @Volatile
31
+ var totalCycles: Int? = null // null = infinite
32
+
33
+ // Current state
34
+ @Volatile
35
+ var state: BreathingExerciseState = BreathingExerciseState.STOPPED
36
+
37
+ @Volatile
38
+ var currentPhaseIndex: Int = 0
39
+
40
+ @Volatile
41
+ var currentCycle: Int = 0
42
+
43
+ @Volatile
44
+ var phaseStartTime: Long = System.nanoTime()
45
+
46
+ @Volatile
47
+ var pauseTime: Long? = null
48
+
49
+ // Animation values
50
+ @Volatile
51
+ var currentScale: Double = 1.0
52
+
53
+ @Volatile
54
+ var targetScale: Double = 1.0
55
+
56
+ @Volatile
57
+ var startScale: Double = 1.0 // Scale at start of current phase
58
+
59
+ @Volatile
60
+ var currentPhase: BreathPhase = BreathPhase.IDLE
61
+
62
+ @Volatile
63
+ var currentLabel: String = ""
64
+
65
+ @Volatile
66
+ var phaseProgress: Double = 0.0
67
+
68
+ // Wobble animation
69
+ @Volatile
70
+ var wobbleIntensity: Double = 1.0
71
+
72
+ @Volatile
73
+ var wobbleOffsets: MutableList<Double> = mutableListOf()
74
+
75
+ @Volatile
76
+ var wobbleTargets: MutableList<Double> = mutableListOf()
77
+
78
+ @Volatile
79
+ var lastWobbleUpdate: Long = System.nanoTime()
80
+
81
+ @Volatile
82
+ var lastWobbleTargetUpdate: Long = System.nanoTime()
83
+
84
+ // Exercise tracking
85
+ @Volatile
86
+ var exerciseStartTime: Long = System.nanoTime()
87
+
88
+ @Volatile
89
+ var totalDuration: Double = 0.0
90
+
91
+ // Callbacks
92
+ var onPhaseChange: ((BreathPhase, String, Int, Int) -> Unit)? = null
93
+ var onExerciseComplete: ((Int, Double) -> Unit)? = null
94
+
95
+ fun reset() {
96
+ state = BreathingExerciseState.STOPPED
97
+ currentPhaseIndex = 0
98
+ currentCycle = 0
99
+ currentScale = 1.0
100
+ targetScale = 1.0
101
+ startScale = 1.0
102
+ currentPhase = BreathPhase.IDLE
103
+ currentLabel = ""
104
+ phaseProgress = 0.0
105
+ wobbleIntensity = 1.0
106
+ pauseTime = null
107
+ }
108
+ }