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 +208 -0
- package/android/build.gradle +2 -0
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +6 -1
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +418 -55
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +4 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +13 -3
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +21 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +21 -0
- package/ios/Breathing/BreathingExerciseView.swift +8 -8
- package/ios/Breathing/BreathingParticlesView.swift +81 -0
- package/ios/Breathing/BreathingTextCue.swift +2 -2
- package/package.json +1 -1
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
|
+
|  |  |
|
|
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
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
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 =
|
|
60
|
-
modifier = Modifier
|
|
61
|
-
.fillMaxSize(0.95f)
|
|
94
|
+
lineWidth = 4f,
|
|
95
|
+
modifier = Modifier.fillMaxSize(0.95f)
|
|
62
96
|
)
|
|
63
97
|
}
|
|
64
98
|
|
|
65
|
-
// Main
|
|
99
|
+
// Main blob with all effects
|
|
66
100
|
Box(
|
|
67
101
|
modifier = Modifier
|
|
68
102
|
.fillMaxSize()
|
|
69
|
-
.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
142
|
-
val
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
updateWobble(frameTime, config)
|
|
507
|
+
state.currentScale = scale
|
|
151
508
|
|
|
152
509
|
AnimationState(
|
|
153
|
-
scale =
|
|
510
|
+
scale = scale,
|
|
154
511
|
wobbleOffsets = state.wobbleOffsets.toList(),
|
|
155
|
-
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 >
|
|
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.
|
|
590
|
+
val maxOffset = 0.18 * intensity
|
|
233
591
|
|
|
234
592
|
for (i in 0 until config.pointCount) {
|
|
235
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
96
|
+
BreathingParticlesView(
|
|
97
97
|
color: config.particleColor,
|
|
98
|
+
particleCount: 15,
|
|
98
99
|
speedRange: 10...20,
|
|
99
|
-
sizeRange:
|
|
100
|
-
|
|
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
|
-
|
|
105
|
+
BreathingParticlesView(
|
|
106
106
|
color: config.particleColor,
|
|
107
|
-
speedRange: 20...30,
|
|
108
|
-
sizeRange: 0.2...1,
|
|
109
107
|
particleCount: 10,
|
|
110
|
-
|
|
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: .
|
|
9
|
+
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
|
10
10
|
.foregroundColor(color)
|
|
11
|
-
.shadow(color:
|
|
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
|
}
|