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.
- package/README.md +209 -1
- package/android/build.gradle +2 -0
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +25 -0
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +610 -0
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +108 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +51 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +177 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +144 -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/BreathingParticlesView.swift +81 -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,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
|
+
}
|