expo-orb 0.2.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.
package/README.md CHANGED
@@ -124,6 +124,214 @@ export default function App() {
124
124
  }
125
125
  ```
126
126
 
127
+ ---
128
+
129
+ ## Breathing Exercise Component
130
+
131
+ A guided breathing exercise component with morphing blob animation, progress ring, and text cues. Supports custom breathing patterns and built-in presets.
132
+
133
+ ### Breathing Demo
134
+
135
+ | iOS | Android |
136
+ |:---:|:-------:|
137
+ | ![iOS Breathing Demo](https://raw.githubusercontent.com/enso-works/expo-orb-animation/main/docs/breathing-ios.gif) | ![Android Breathing Demo](https://raw.githubusercontent.com/enso-works/expo-orb-animation/main/docs/breathing-android.gif) |
138
+
139
+ ### Basic Usage
140
+
141
+ ```tsx
142
+ import {
143
+ ExpoBreathingExerciseView,
144
+ startBreathingExercise,
145
+ stopBreathingExercise,
146
+ getBreathingPreset,
147
+ } from 'expo-orb';
148
+
149
+ function BreathingScreen() {
150
+ const handleStart = () => {
151
+ // Use a built-in preset
152
+ startBreathingExercise(getBreathingPreset('box'));
153
+ };
154
+
155
+ return (
156
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
157
+ <ExpoBreathingExerciseView
158
+ style={{ width: 300, height: 300 }}
159
+ blobColors={['#7c3aed', '#3b82f6', '#ec4899']}
160
+ showProgressRing={true}
161
+ showTextCue={true}
162
+ onPhaseChange={(e) => console.log('Phase:', e.nativeEvent.label)}
163
+ onExerciseComplete={(e) => console.log('Done! Cycles:', e.nativeEvent.totalCycles)}
164
+ />
165
+ <Button title="Start" onPress={handleStart} />
166
+ <Button title="Stop" onPress={stopBreathingExercise} />
167
+ </View>
168
+ );
169
+ }
170
+ ```
171
+
172
+ ### Breathing Props
173
+
174
+ | Prop | Type | Default | Description |
175
+ |------|------|---------|-------------|
176
+ | `blobColors` | `ColorValue[]` | `['green', 'blue', 'pink']` | Gradient colors for the morphing blob |
177
+ | `innerBlobColor` | `ColorValue` | `'white'` | Color of the inner blob |
178
+ | `glowColor` | `ColorValue` | `'white'` | Color of the glow effects |
179
+ | `particleColor` | `ColorValue` | `'white'` | Color of floating particles |
180
+ | `progressRingColor` | `ColorValue` | `'white'` | Color of the progress ring |
181
+ | `textColor` | `ColorValue` | `'white'` | Color of the instruction text |
182
+ | `showProgressRing` | `boolean` | `true` | Show/hide the progress ring |
183
+ | `showTextCue` | `boolean` | `true` | Show/hide the instruction text (e.g., "Breathe In") |
184
+ | `showInnerBlob` | `boolean` | `true` | Show/hide the inner blob |
185
+ | `showShadow` | `boolean` | `true` | Show/hide drop shadow |
186
+ | `showParticles` | `boolean` | `true` | Show/hide floating particles |
187
+ | `showWavyBlobs` | `boolean` | `true` | Show/hide wavy blob overlays |
188
+ | `showGlowEffects` | `boolean` | `true` | Show/hide rotating glow effects |
189
+ | `pointCount` | `number` | `8` | Number of points for blob morphing |
190
+ | `wobbleIntensity` | `number` | `0.5` | Intensity of wobble animation (0-1) |
191
+ | `style` | `StyleProp<ViewStyle>` | - | Container style (set width/height here) |
192
+
193
+ ### Breathing Events
194
+
195
+ | Event | Payload | Description |
196
+ |-------|---------|-------------|
197
+ | `onPhaseChange` | `{ phase, label, phaseIndex, cycle }` | Fired when the breathing phase changes |
198
+ | `onExerciseComplete` | `{ totalCycles, totalDuration }` | Fired when all cycles complete |
199
+
200
+ ### Breathing Control Functions
201
+
202
+ ```tsx
203
+ import {
204
+ startBreathingExercise,
205
+ stopBreathingExercise,
206
+ pauseBreathingExercise,
207
+ resumeBreathingExercise,
208
+ getBreathingPreset,
209
+ } from 'expo-orb';
210
+ ```
211
+
212
+ | Function | Description |
213
+ |----------|-------------|
214
+ | `startBreathingExercise(pattern)` | Start the exercise with a custom pattern or preset |
215
+ | `stopBreathingExercise()` | Stop the current exercise |
216
+ | `pauseBreathingExercise()` | Pause the current exercise |
217
+ | `resumeBreathingExercise()` | Resume a paused exercise |
218
+ | `getBreathingPreset(name)` | Get a built-in preset by name |
219
+
220
+ ### Built-in Presets
221
+
222
+ | Preset | Pattern | Cycles | Description |
223
+ |--------|---------|--------|-------------|
224
+ | `'relaxing'` | 4s in, 7s hold, 8s out | 4 | 4-7-8 technique for relaxation |
225
+ | `'box'` | 4s in, 4s hold, 4s out, 4s hold | 4 | Box breathing for focus |
226
+ | `'energizing'` | 2s in, 2s out | 10 | Quick breathing for energy |
227
+ | `'calming'` | 4s in, 6s out | 6 | Extended exhale for calm |
228
+
229
+ ### Custom Breathing Patterns
230
+
231
+ Create your own breathing patterns:
232
+
233
+ ```tsx
234
+ import { startBreathingExercise, BreathingPattern } from 'expo-orb';
235
+
236
+ const customPattern: BreathingPattern = {
237
+ phases: [
238
+ { phase: 'inhale', duration: 5000, targetScale: 1.35, label: 'Breathe In' },
239
+ { phase: 'holdIn', duration: 3000, targetScale: 1.35, label: 'Hold' },
240
+ { phase: 'exhale', duration: 7000, targetScale: 0.75, label: 'Breathe Out' },
241
+ { phase: 'holdOut', duration: 2000, targetScale: 0.75, label: 'Rest' },
242
+ ],
243
+ cycles: 5, // undefined for infinite
244
+ };
245
+
246
+ startBreathingExercise(customPattern);
247
+ ```
248
+
249
+ ### Phase Types
250
+
251
+ | Phase | Description |
252
+ |-------|-------------|
253
+ | `'inhale'` | Breathing in - blob expands |
254
+ | `'holdIn'` | Holding breath after inhale |
255
+ | `'exhale'` | Breathing out - blob contracts |
256
+ | `'holdOut'` | Holding breath after exhale |
257
+
258
+ ### Complete Breathing Example
259
+
260
+ ```tsx
261
+ import * as React from 'react';
262
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
263
+ import {
264
+ ExpoBreathingExerciseView,
265
+ startBreathingExercise,
266
+ stopBreathingExercise,
267
+ pauseBreathingExercise,
268
+ resumeBreathingExercise,
269
+ getBreathingPreset,
270
+ BreathingPreset,
271
+ } from 'expo-orb';
272
+
273
+ export default function BreathingApp() {
274
+ const [currentPhase, setCurrentPhase] = React.useState('');
275
+ const [isPaused, setIsPaused] = React.useState(false);
276
+
277
+ const startPreset = (preset: BreathingPreset) => {
278
+ setIsPaused(false);
279
+ startBreathingExercise(getBreathingPreset(preset));
280
+ };
281
+
282
+ return (
283
+ <View style={styles.container}>
284
+ <ExpoBreathingExerciseView
285
+ style={styles.breathingView}
286
+ blobColors={['#667eea', '#764ba2', '#f093fb']}
287
+ progressRingColor="#ffffff"
288
+ textColor="#ffffff"
289
+ showProgressRing={true}
290
+ showTextCue={true}
291
+ showParticles={true}
292
+ onPhaseChange={(e) => setCurrentPhase(e.nativeEvent.label)}
293
+ onExerciseComplete={() => setCurrentPhase('Complete!')}
294
+ />
295
+
296
+ <View style={styles.presets}>
297
+ <TouchableOpacity onPress={() => startPreset('box')}>
298
+ <Text>Box</Text>
299
+ </TouchableOpacity>
300
+ <TouchableOpacity onPress={() => startPreset('relaxing')}>
301
+ <Text>Relaxing</Text>
302
+ </TouchableOpacity>
303
+ <TouchableOpacity onPress={() => startPreset('calming')}>
304
+ <Text>Calming</Text>
305
+ </TouchableOpacity>
306
+ </View>
307
+
308
+ <View style={styles.controls}>
309
+ <TouchableOpacity onPress={() => {
310
+ if (isPaused) {
311
+ resumeBreathingExercise();
312
+ } else {
313
+ pauseBreathingExercise();
314
+ }
315
+ setIsPaused(!isPaused);
316
+ }}>
317
+ <Text>{isPaused ? 'Resume' : 'Pause'}</Text>
318
+ </TouchableOpacity>
319
+ <TouchableOpacity onPress={stopBreathingExercise}>
320
+ <Text>Stop</Text>
321
+ </TouchableOpacity>
322
+ </View>
323
+ </View>
324
+ );
325
+ }
326
+
327
+ const styles = StyleSheet.create({
328
+ container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
329
+ breathingView: { width: 280, height: 280 },
330
+ presets: { flexDirection: 'row', gap: 16, marginTop: 32 },
331
+ controls: { flexDirection: 'row', gap: 24, marginTop: 16 },
332
+ });
333
+ ```
334
+
127
335
  ## License
128
336
 
129
337
  MIT
@@ -56,5 +56,7 @@ dependencies {
56
56
  implementation 'androidx.compose.ui:ui-graphics'
57
57
  implementation 'androidx.compose.foundation:foundation'
58
58
  implementation 'androidx.compose.runtime:runtime'
59
+ implementation 'androidx.compose.material3:material3'
60
+ implementation 'androidx.compose.animation:animation'
59
61
  implementation 'androidx.activity:activity-compose:1.8.2'
60
62
  }
@@ -9,12 +9,17 @@ data class BreathingConfiguration(
9
9
  Color(0xFF804DB3) // Purple
10
10
  ),
11
11
  val innerBlobColor: Color = Color.White.copy(alpha = 0.3f),
12
+ val glowColor: Color = Color.White,
13
+ val particleColor: Color = Color.White,
12
14
  val progressRingColor: Color = Color.White.copy(alpha = 0.5f),
13
15
  val textColor: Color = Color.White,
14
16
  val showProgressRing: Boolean = true,
15
17
  val showTextCue: Boolean = true,
16
18
  val showInnerBlob: Boolean = true,
17
19
  val showShadow: Boolean = true,
18
- val pointCount: Int = 28,
20
+ val showParticles: Boolean = true,
21
+ val showWavyBlobs: Boolean = true,
22
+ val showGlowEffects: Boolean = true,
23
+ val pointCount: Int = 8,
19
24
  val wobbleIntensity: Double = 1.0
20
25
  )
@@ -1,8 +1,12 @@
1
1
  package expo.modules.breathing
2
2
 
3
+ import androidx.compose.foundation.Canvas
4
+ import androidx.compose.foundation.background
3
5
  import androidx.compose.foundation.layout.Box
4
6
  import androidx.compose.foundation.layout.aspectRatio
5
7
  import androidx.compose.foundation.layout.fillMaxSize
8
+ import androidx.compose.foundation.layout.offset
9
+ import androidx.compose.foundation.layout.padding
6
10
  import androidx.compose.runtime.Composable
7
11
  import androidx.compose.runtime.LaunchedEffect
8
12
  import androidx.compose.runtime.getValue
@@ -13,10 +17,30 @@ import androidx.compose.runtime.setValue
13
17
  import androidx.compose.runtime.withFrameNanos
14
18
  import androidx.compose.ui.Alignment
15
19
  import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.draw.blur
21
+ import androidx.compose.ui.draw.clip
16
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
17
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
18
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
19
40
  import kotlin.math.min
41
+ import kotlin.math.pow
42
+ import kotlin.math.sin
43
+ import kotlin.math.tan
20
44
  import kotlin.random.Random
21
45
 
22
46
  @Composable
@@ -26,12 +50,15 @@ fun BreathingExerciseView(
26
50
  ) {
27
51
  // Frame-based animation state
28
52
  var frameTime by remember { mutableLongStateOf(System.nanoTime()) }
53
+ var elapsedTime by remember { mutableStateOf(0.0) }
29
54
 
30
55
  // Continuous animation loop
31
56
  LaunchedEffect(Unit) {
57
+ val startTime = System.nanoTime()
32
58
  while (true) {
33
59
  withFrameNanos { currentTime ->
34
60
  frameTime = currentTime
61
+ elapsedTime = (currentTime - startTime) / 1_000_000_000.0
35
62
  }
36
63
  }
37
64
  }
@@ -46,53 +73,122 @@ fun BreathingExerciseView(
46
73
  // Update animation state
47
74
  val animationState = updateAnimationState(frameTime, config)
48
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
+
49
85
  Box(
50
- modifier = modifier
51
- .aspectRatio(1f),
86
+ modifier = modifier.aspectRatio(1f),
52
87
  contentAlignment = Alignment.Center
53
88
  ) {
54
89
  // Progress ring (behind blob)
55
- if (config.showProgressRing) {
90
+ if (config.showProgressRing && animationState.isActive) {
56
91
  ProgressRingView(
57
92
  progress = animationState.phaseProgress,
58
93
  color = config.progressRingColor,
59
- lineWidth = 8f,
60
- modifier = Modifier
61
- .fillMaxSize(0.95f)
94
+ lineWidth = 4f,
95
+ modifier = Modifier.fillMaxSize(0.95f)
62
96
  )
63
97
  }
64
98
 
65
- // Main morphing blob with shadow
99
+ // Main blob with all effects
66
100
  Box(
67
101
  modifier = Modifier
68
102
  .fillMaxSize()
69
- .then(
70
- if (config.showShadow) {
71
- Modifier.drawBehind {
72
- // Simple shadow approximation
73
- drawCircle(
74
- color = config.blobColors.firstOrNull()?.copy(alpha = 0.3f) ?: Color.Transparent,
75
- radius = size.minDimension / 2 * animationState.scale.toFloat() * 0.7f,
76
- center = center.copy(y = center.y + 8.dp.toPx())
77
- )
78
- }
79
- } else Modifier
80
- ),
81
- contentAlignment = Alignment.Center
103
+ .graphicsLayer { clip = false }
82
104
  ) {
83
- MorphingBlobView(
84
- baseRadius = animationState.scale.toFloat() * 0.7f,
85
- pointCount = config.pointCount,
86
- offsets = animationState.wobbleOffsets,
87
- colors = config.blobColors,
88
- innerColor = config.innerBlobColor,
89
- showInnerBlob = config.showInnerBlob,
90
- modifier = Modifier.fillMaxSize()
91
- )
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
+ }
92
188
  }
93
189
 
94
- // Text cue
95
- if (config.showTextCue) {
190
+ // Text cue (on top)
191
+ if (config.showTextCue && animationState.label.isNotEmpty()) {
96
192
  BreathingTextCue(
97
193
  text = animationState.label,
98
194
  color = config.textColor
@@ -101,27 +197,261 @@ fun BreathingExerciseView(
101
197
  }
102
198
  }
103
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
+
104
277
  private data class AnimationState(
105
278
  val scale: Double,
106
279
  val wobbleOffsets: List<Double>,
107
280
  val phaseProgress: Double,
108
- val label: String
281
+ val label: String,
282
+ val isActive: Boolean
109
283
  )
110
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
+
111
438
  private fun updateAnimationState(
112
439
  frameTime: Long,
113
440
  config: BreathingConfiguration
114
441
  ): AnimationState {
115
442
  val state = BreathingSharedState
116
443
 
117
- // Handle different states
444
+ // Always update wobble animation
445
+ updateWobble(frameTime, config)
446
+
118
447
  return when (state.state) {
119
448
  BreathingExerciseState.STOPPED, BreathingExerciseState.COMPLETE -> {
120
449
  AnimationState(
121
450
  scale = 1.0,
122
- wobbleOffsets = List(config.pointCount) { 0.0 },
451
+ wobbleOffsets = state.wobbleOffsets.toList(),
123
452
  phaseProgress = 0.0,
124
- label = ""
453
+ label = "",
454
+ isActive = false
125
455
  )
126
456
  }
127
457
 
@@ -130,7 +460,8 @@ private fun updateAnimationState(
130
460
  scale = state.currentScale,
131
461
  wobbleOffsets = state.wobbleOffsets.toList(),
132
462
  phaseProgress = state.phaseProgress,
133
- label = state.currentLabel
463
+ label = state.currentLabel,
464
+ isActive = true
134
465
  )
135
466
  }
136
467
 
@@ -138,22 +469,49 @@ private fun updateAnimationState(
138
469
  // Update phase timing
139
470
  updatePhaseState(frameTime)
140
471
 
141
- // Interpolate scale toward target
142
- val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
143
- state.lastWobbleUpdate = frameTime
144
-
145
- // Smooth scale interpolation
146
- val scaleFactor = min(1.0, dt * 3.0)
147
- state.currentScale = state.currentScale + (state.targetScale - state.currentScale) * scaleFactor
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
+ }
148
506
 
149
- // Update wobble animation
150
- updateWobble(frameTime, config)
507
+ state.currentScale = scale
151
508
 
152
509
  AnimationState(
153
- scale = state.currentScale,
510
+ scale = scale,
154
511
  wobbleOffsets = state.wobbleOffsets.toList(),
155
- phaseProgress = state.phaseProgress,
156
- label = state.currentLabel
512
+ phaseProgress = ringProgress,
513
+ label = state.currentLabel,
514
+ isActive = true
157
515
  )
158
516
  }
159
517
  }
@@ -194,6 +552,7 @@ private fun updatePhaseState(frameTime: Long) {
194
552
  val newPhaseConfig = state.phases[state.currentPhaseIndex]
195
553
  state.currentPhase = newPhaseConfig.phase
196
554
  state.currentLabel = newPhaseConfig.label
555
+ state.startScale = state.currentScale
197
556
  state.targetScale = newPhaseConfig.targetScale
198
557
  state.phaseProgress = 0.0
199
558
 
@@ -216,7 +575,6 @@ private fun updatePhaseState(frameTime: Long) {
216
575
 
217
576
  private fun updateWobble(frameTime: Long, config: BreathingConfiguration) {
218
577
  val state = BreathingSharedState
219
- val wobbleUpdateInterval = 0.8 // New targets every 0.8 seconds
220
578
 
221
579
  // Ensure arrays are sized correctly
222
580
  if (state.wobbleOffsets.size != config.pointCount) {
@@ -224,21 +582,26 @@ private fun updateWobble(frameTime: Long, config: BreathingConfiguration) {
224
582
  state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
225
583
  }
226
584
 
227
- // Check if we need new random targets
585
+ // Check if we need new random targets (slower, more organic)
228
586
  val timeSinceLastTarget = (frameTime - state.lastWobbleTargetUpdate) / 1_000_000_000.0
229
- if (timeSinceLastTarget > wobbleUpdateInterval || state.wobbleTargets.all { it == 0.0 }) {
587
+ if (timeSinceLastTarget > 1.8 || state.wobbleTargets.all { it == 0.0 }) {
230
588
  state.lastWobbleTargetUpdate = frameTime
231
589
  val intensity = state.wobbleIntensity * config.wobbleIntensity
232
- val maxOffset = 0.08 * intensity // Max 8% radius variation at full intensity
590
+ val maxOffset = 0.18 * intensity
233
591
 
234
592
  for (i in 0 until config.pointCount) {
235
- state.wobbleTargets[i] = Random.nextDouble(-maxOffset, maxOffset)
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
236
598
  }
237
599
  }
238
600
 
239
- // Interpolate offsets toward targets
240
- val interpolationSpeed = 4.0
601
+ // Smoothly interpolate offsets toward targets
241
602
  val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
603
+ state.lastWobbleUpdate = frameTime
604
+ val interpolationSpeed = 1.8
242
605
  val factor = min(1.0, dt * interpolationSpeed)
243
606
 
244
607
  for (i in 0 until min(state.wobbleOffsets.size, state.wobbleTargets.size)) {
@@ -53,6 +53,9 @@ object BreathingSharedState {
53
53
  @Volatile
54
54
  var targetScale: Double = 1.0
55
55
 
56
+ @Volatile
57
+ var startScale: Double = 1.0 // Scale at start of current phase
58
+
56
59
  @Volatile
57
60
  var currentPhase: BreathPhase = BreathPhase.IDLE
58
61
 
@@ -95,6 +98,7 @@ object BreathingSharedState {
95
98
  currentCycle = 0
96
99
  currentScale = 1.0
97
100
  targetScale = 1.0
101
+ startScale = 1.0
98
102
  currentPhase = BreathPhase.IDLE
99
103
  currentLabel = ""
100
104
  phaseProgress = 0.0
@@ -9,7 +9,10 @@ import androidx.compose.material3.Text
9
9
  import androidx.compose.runtime.Composable
10
10
  import androidx.compose.ui.Alignment
11
11
  import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.geometry.Offset
12
13
  import androidx.compose.ui.graphics.Color
14
+ import androidx.compose.ui.graphics.Shadow
15
+ import androidx.compose.ui.text.TextStyle
13
16
  import androidx.compose.ui.text.font.FontWeight
14
17
  import androidx.compose.ui.unit.sp
15
18
 
@@ -32,9 +35,16 @@ fun BreathingTextCue(
32
35
  ) { currentText ->
33
36
  Text(
34
37
  text = currentText,
35
- color = color,
36
- fontSize = 24.sp,
37
- fontWeight = FontWeight.Medium
38
+ style = TextStyle(
39
+ color = color,
40
+ fontSize = 24.sp,
41
+ fontWeight = FontWeight.SemiBold,
42
+ shadow = Shadow(
43
+ color = Color.Black.copy(alpha = 0.5f),
44
+ offset = Offset(0f, 2f),
45
+ blurRadius = 8f
46
+ )
47
+ )
38
48
  )
39
49
  }
40
50
  }
@@ -74,6 +74,26 @@ class ExpoBreathingExerciseModule : Module() {
74
74
  view.setShowShadow(value)
75
75
  }
76
76
 
77
+ Prop("showParticles") { view: ExpoBreathingExerciseView, value: Boolean ->
78
+ view.setShowParticles(value)
79
+ }
80
+
81
+ Prop("showWavyBlobs") { view: ExpoBreathingExerciseView, value: Boolean ->
82
+ view.setShowWavyBlobs(value)
83
+ }
84
+
85
+ Prop("showGlowEffects") { view: ExpoBreathingExerciseView, value: Boolean ->
86
+ view.setShowGlowEffects(value)
87
+ }
88
+
89
+ Prop("glowColor") { view: ExpoBreathingExerciseView, color: Any ->
90
+ view.setGlowColor(parseColor(color))
91
+ }
92
+
93
+ Prop("particleColor") { view: ExpoBreathingExerciseView, color: Any ->
94
+ view.setParticleColor(parseColor(color))
95
+ }
96
+
77
97
  Prop("pointCount") { view: ExpoBreathingExerciseView, value: Int ->
78
98
  view.setPointCount(value)
79
99
  }
@@ -130,6 +150,7 @@ class ExpoBreathingExerciseModule : Module() {
130
150
  val firstPhase = phases[0]
131
151
  state.currentPhase = firstPhase.phase
132
152
  state.currentLabel = firstPhase.label
153
+ state.startScale = 1.0
133
154
  state.targetScale = firstPhase.targetScale
134
155
  state.currentScale = 1.0
135
156
  state.phaseProgress = 0.0
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
14
14
  import androidx.compose.ui.platform.ComposeView
15
15
  import androidx.compose.ui.unit.dp
16
16
  import expo.modules.kotlin.AppContext
17
+ import expo.modules.kotlin.viewevent.EventDispatcher
17
18
  import expo.modules.kotlin.views.ExpoView
18
19
 
19
20
  class ExpoBreathingExerciseView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
@@ -120,4 +121,24 @@ class ExpoBreathingExerciseView(context: Context, appContext: AppContext) : Expo
120
121
  fun setWobbleIntensity(value: Double) {
121
122
  config = config.copy(wobbleIntensity = value.coerceIn(0.0, 1.0))
122
123
  }
124
+
125
+ fun setGlowColor(color: Int) {
126
+ config = config.copy(glowColor = Color(color))
127
+ }
128
+
129
+ fun setParticleColor(color: Int) {
130
+ config = config.copy(particleColor = Color(color))
131
+ }
132
+
133
+ fun setShowParticles(value: Boolean) {
134
+ config = config.copy(showParticles = value)
135
+ }
136
+
137
+ fun setShowWavyBlobs(value: Boolean) {
138
+ config = config.copy(showWavyBlobs = value)
139
+ }
140
+
141
+ fun setShowGlowEffects(value: Boolean) {
142
+ config = config.copy(showGlowEffects = value)
143
+ }
123
144
  }
@@ -93,21 +93,21 @@ public struct BreathingExerciseView: View {
93
93
 
94
94
  private var particleView: some View {
95
95
  ZStack {
96
- ParticlesView(
96
+ BreathingParticlesView(
97
97
  color: config.particleColor,
98
+ particleCount: 15,
98
99
  speedRange: 10...20,
99
- sizeRange: 0.5...1,
100
- particleCount: 10,
101
- opacityRange: 0...0.3
100
+ sizeRange: 1...3,
101
+ opacityRange: 0.1...0.4
102
102
  )
103
103
  .blur(radius: 1)
104
104
 
105
- ParticlesView(
105
+ BreathingParticlesView(
106
106
  color: config.particleColor,
107
- speedRange: 20...30,
108
- sizeRange: 0.2...1,
109
107
  particleCount: 10,
110
- opacityRange: 0.3...0.8
108
+ speedRange: 20...35,
109
+ sizeRange: 0.5...2,
110
+ opacityRange: 0.3...0.7
111
111
  )
112
112
  }
113
113
  .blendMode(.plusLighter)
@@ -0,0 +1,81 @@
1
+ import SwiftUI
2
+
3
+ /// Pure SwiftUI particle system - no SpriteKit, no console warnings
4
+ struct BreathingParticlesView: View {
5
+ let color: Color
6
+ let particleCount: Int
7
+ let speedRange: ClosedRange<Double>
8
+ let sizeRange: ClosedRange<CGFloat>
9
+ let opacityRange: ClosedRange<Double>
10
+
11
+ init(
12
+ color: Color = .white,
13
+ particleCount: Int = 20,
14
+ speedRange: ClosedRange<Double> = 10...30,
15
+ sizeRange: ClosedRange<CGFloat> = 0.5...2.0,
16
+ opacityRange: ClosedRange<Double> = 0.1...0.8
17
+ ) {
18
+ self.color = color
19
+ self.particleCount = particleCount
20
+ self.speedRange = speedRange
21
+ self.sizeRange = sizeRange
22
+ self.opacityRange = opacityRange
23
+ }
24
+
25
+ var body: some View {
26
+ TimelineView(.animation) { timeline in
27
+ Canvas { context, size in
28
+ let time = timeline.date.timeIntervalSinceReferenceDate
29
+
30
+ for i in 0..<particleCount {
31
+ // Use deterministic seed based on particle index
32
+ let seed = Double(i) * 1.618033988749895 // Golden ratio for good distribution
33
+
34
+ // Particle properties derived from seed
35
+ let baseX = fract(seed * 0.7)
36
+ let _ = fract(seed * 1.3) // Reserved for future horizontal variation
37
+ let particleSpeed = speedRange.lowerBound + fract(seed * 2.1) * (speedRange.upperBound - speedRange.lowerBound)
38
+ let particleSize = sizeRange.lowerBound + CGFloat(fract(seed * 3.7)) * (sizeRange.upperBound - sizeRange.lowerBound)
39
+ let baseOpacity = opacityRange.lowerBound + fract(seed * 4.3) * (opacityRange.upperBound - opacityRange.lowerBound)
40
+ let lifetime = 2.0 + fract(seed * 5.1) * 2.0 // 2-4 seconds
41
+
42
+ // Calculate current position in lifecycle
43
+ let phase = fract((time * particleSpeed / 100.0 + seed) / lifetime)
44
+
45
+ // Y position: rise from bottom to top
46
+ let y = size.height * (1.0 - phase)
47
+
48
+ // X position: slight horizontal drift
49
+ let drift = sin(time * 0.5 + seed * 10) * 20
50
+ let x = baseX * size.width + drift
51
+
52
+ // Opacity: fade in and out
53
+ let fadeIn = min(1.0, phase * 5.0) // Quick fade in
54
+ let fadeOut = min(1.0, (1.0 - phase) * 3.0) // Slower fade out
55
+ let opacity = baseOpacity * fadeIn * fadeOut
56
+
57
+ // Scale: grow then shrink
58
+ let scalePhase = phase < 0.3 ? phase / 0.3 : (1.0 - (phase - 0.3) / 0.7)
59
+ let scale = 0.5 + scalePhase * 0.5
60
+
61
+ // Draw particle
62
+ let rect = CGRect(
63
+ x: x - particleSize * scale / 2,
64
+ y: y - particleSize * scale / 2,
65
+ width: particleSize * scale,
66
+ height: particleSize * scale
67
+ )
68
+
69
+ context.fill(
70
+ Circle().path(in: rect),
71
+ with: .color(color.opacity(opacity))
72
+ )
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ private func fract(_ x: Double) -> Double {
79
+ x - floor(x)
80
+ }
81
+ }
@@ -6,9 +6,9 @@ struct BreathingTextCue: View {
6
6
 
7
7
  var body: some View {
8
8
  Text(text)
9
- .font(.system(size: 24, weight: .medium, design: .rounded))
9
+ .font(.system(size: 24, weight: .semibold, design: .rounded))
10
10
  .foregroundColor(color)
11
- .shadow(color: color.opacity(0.5), radius: 4, x: 0, y: 2)
11
+ .shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 2)
12
12
  .animation(.easeInOut(duration: 0.3), value: text)
13
13
  }
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-orb",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Animated orb and breathing exercise components for React Native (iOS, Android)",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",