expo-orb 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +20 -0
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +247 -0
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +104 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +41 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +156 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +123 -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/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
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Inspired by and iOS implementation from [metasidd/Orb](https://github.com/metasi
|
|
|
8
8
|
|
|
9
9
|
| iOS | Android |
|
|
10
10
|
|:---:|:-------:|
|
|
11
|
-
|  |  |
|
|
11
|
+
|  |  |
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import androidx.compose.ui.graphics.Color
|
|
4
|
+
|
|
5
|
+
data class BreathingConfiguration(
|
|
6
|
+
val blobColors: List<Color> = listOf(
|
|
7
|
+
Color(0xFF66B3E6), // Light blue
|
|
8
|
+
Color(0xFF4D80CC), // Medium blue
|
|
9
|
+
Color(0xFF804DB3) // Purple
|
|
10
|
+
),
|
|
11
|
+
val innerBlobColor: Color = Color.White.copy(alpha = 0.3f),
|
|
12
|
+
val progressRingColor: Color = Color.White.copy(alpha = 0.5f),
|
|
13
|
+
val textColor: Color = Color.White,
|
|
14
|
+
val showProgressRing: Boolean = true,
|
|
15
|
+
val showTextCue: Boolean = true,
|
|
16
|
+
val showInnerBlob: Boolean = true,
|
|
17
|
+
val showShadow: Boolean = true,
|
|
18
|
+
val pointCount: Int = 28,
|
|
19
|
+
val wobbleIntensity: Double = 1.0
|
|
20
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.Box
|
|
4
|
+
import androidx.compose.foundation.layout.aspectRatio
|
|
5
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
6
|
+
import androidx.compose.runtime.Composable
|
|
7
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
8
|
+
import androidx.compose.runtime.getValue
|
|
9
|
+
import androidx.compose.runtime.mutableLongStateOf
|
|
10
|
+
import androidx.compose.runtime.mutableStateOf
|
|
11
|
+
import androidx.compose.runtime.remember
|
|
12
|
+
import androidx.compose.runtime.setValue
|
|
13
|
+
import androidx.compose.runtime.withFrameNanos
|
|
14
|
+
import androidx.compose.ui.Alignment
|
|
15
|
+
import androidx.compose.ui.Modifier
|
|
16
|
+
import androidx.compose.ui.draw.drawBehind
|
|
17
|
+
import androidx.compose.ui.graphics.Color
|
|
18
|
+
import androidx.compose.ui.unit.dp
|
|
19
|
+
import kotlin.math.min
|
|
20
|
+
import kotlin.random.Random
|
|
21
|
+
|
|
22
|
+
@Composable
|
|
23
|
+
fun BreathingExerciseView(
|
|
24
|
+
config: BreathingConfiguration,
|
|
25
|
+
modifier: Modifier = Modifier
|
|
26
|
+
) {
|
|
27
|
+
// Frame-based animation state
|
|
28
|
+
var frameTime by remember { mutableLongStateOf(System.nanoTime()) }
|
|
29
|
+
|
|
30
|
+
// Continuous animation loop
|
|
31
|
+
LaunchedEffect(Unit) {
|
|
32
|
+
while (true) {
|
|
33
|
+
withFrameNanos { currentTime ->
|
|
34
|
+
frameTime = currentTime
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Initialize wobble offsets if needed
|
|
40
|
+
val state = BreathingSharedState
|
|
41
|
+
if (state.wobbleOffsets.size != config.pointCount) {
|
|
42
|
+
state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
|
|
43
|
+
state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Update animation state
|
|
47
|
+
val animationState = updateAnimationState(frameTime, config)
|
|
48
|
+
|
|
49
|
+
Box(
|
|
50
|
+
modifier = modifier
|
|
51
|
+
.aspectRatio(1f),
|
|
52
|
+
contentAlignment = Alignment.Center
|
|
53
|
+
) {
|
|
54
|
+
// Progress ring (behind blob)
|
|
55
|
+
if (config.showProgressRing) {
|
|
56
|
+
ProgressRingView(
|
|
57
|
+
progress = animationState.phaseProgress,
|
|
58
|
+
color = config.progressRingColor,
|
|
59
|
+
lineWidth = 8f,
|
|
60
|
+
modifier = Modifier
|
|
61
|
+
.fillMaxSize(0.95f)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Main morphing blob with shadow
|
|
66
|
+
Box(
|
|
67
|
+
modifier = Modifier
|
|
68
|
+
.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
|
|
82
|
+
) {
|
|
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
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Text cue
|
|
95
|
+
if (config.showTextCue) {
|
|
96
|
+
BreathingTextCue(
|
|
97
|
+
text = animationState.label,
|
|
98
|
+
color = config.textColor
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private data class AnimationState(
|
|
105
|
+
val scale: Double,
|
|
106
|
+
val wobbleOffsets: List<Double>,
|
|
107
|
+
val phaseProgress: Double,
|
|
108
|
+
val label: String
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
private fun updateAnimationState(
|
|
112
|
+
frameTime: Long,
|
|
113
|
+
config: BreathingConfiguration
|
|
114
|
+
): AnimationState {
|
|
115
|
+
val state = BreathingSharedState
|
|
116
|
+
|
|
117
|
+
// Handle different states
|
|
118
|
+
return when (state.state) {
|
|
119
|
+
BreathingExerciseState.STOPPED, BreathingExerciseState.COMPLETE -> {
|
|
120
|
+
AnimationState(
|
|
121
|
+
scale = 1.0,
|
|
122
|
+
wobbleOffsets = List(config.pointCount) { 0.0 },
|
|
123
|
+
phaseProgress = 0.0,
|
|
124
|
+
label = ""
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
BreathingExerciseState.PAUSED -> {
|
|
129
|
+
AnimationState(
|
|
130
|
+
scale = state.currentScale,
|
|
131
|
+
wobbleOffsets = state.wobbleOffsets.toList(),
|
|
132
|
+
phaseProgress = state.phaseProgress,
|
|
133
|
+
label = state.currentLabel
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
BreathingExerciseState.RUNNING -> {
|
|
138
|
+
// Update phase timing
|
|
139
|
+
updatePhaseState(frameTime)
|
|
140
|
+
|
|
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
|
|
148
|
+
|
|
149
|
+
// Update wobble animation
|
|
150
|
+
updateWobble(frameTime, config)
|
|
151
|
+
|
|
152
|
+
AnimationState(
|
|
153
|
+
scale = state.currentScale,
|
|
154
|
+
wobbleOffsets = state.wobbleOffsets.toList(),
|
|
155
|
+
phaseProgress = state.phaseProgress,
|
|
156
|
+
label = state.currentLabel
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private fun updatePhaseState(frameTime: Long) {
|
|
163
|
+
val state = BreathingSharedState
|
|
164
|
+
if (state.phases.isEmpty()) return
|
|
165
|
+
|
|
166
|
+
val currentPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
167
|
+
val elapsed = (frameTime - state.phaseStartTime) / 1_000_000_000.0
|
|
168
|
+
val duration = currentPhaseConfig.duration
|
|
169
|
+
|
|
170
|
+
// Calculate progress through current phase
|
|
171
|
+
state.phaseProgress = min(1.0, elapsed / duration)
|
|
172
|
+
|
|
173
|
+
// Check if phase is complete
|
|
174
|
+
if (elapsed >= duration) {
|
|
175
|
+
// Move to next phase
|
|
176
|
+
state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.size
|
|
177
|
+
|
|
178
|
+
// Check if we completed a cycle
|
|
179
|
+
if (state.currentPhaseIndex == 0) {
|
|
180
|
+
state.currentCycle += 1
|
|
181
|
+
|
|
182
|
+
// Check if exercise is complete
|
|
183
|
+
val totalCycles = state.totalCycles
|
|
184
|
+
if (totalCycles != null && state.currentCycle >= totalCycles) {
|
|
185
|
+
state.state = BreathingExerciseState.COMPLETE
|
|
186
|
+
state.totalDuration = (frameTime - state.exerciseStartTime) / 1_000_000_000.0
|
|
187
|
+
state.onExerciseComplete?.invoke(state.currentCycle, state.totalDuration)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Start new phase
|
|
193
|
+
state.phaseStartTime = frameTime
|
|
194
|
+
val newPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
195
|
+
state.currentPhase = newPhaseConfig.phase
|
|
196
|
+
state.currentLabel = newPhaseConfig.label
|
|
197
|
+
state.targetScale = newPhaseConfig.targetScale
|
|
198
|
+
state.phaseProgress = 0.0
|
|
199
|
+
|
|
200
|
+
// Update wobble intensity based on phase
|
|
201
|
+
state.wobbleIntensity = when (newPhaseConfig.phase) {
|
|
202
|
+
BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
|
|
203
|
+
BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
|
|
204
|
+
BreathPhase.IDLE -> 0.5
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Fire phase change callback
|
|
208
|
+
state.onPhaseChange?.invoke(
|
|
209
|
+
newPhaseConfig.phase,
|
|
210
|
+
newPhaseConfig.label,
|
|
211
|
+
state.currentPhaseIndex,
|
|
212
|
+
state.currentCycle
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun updateWobble(frameTime: Long, config: BreathingConfiguration) {
|
|
218
|
+
val state = BreathingSharedState
|
|
219
|
+
val wobbleUpdateInterval = 0.8 // New targets every 0.8 seconds
|
|
220
|
+
|
|
221
|
+
// Ensure arrays are sized correctly
|
|
222
|
+
if (state.wobbleOffsets.size != config.pointCount) {
|
|
223
|
+
state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
|
|
224
|
+
state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Check if we need new random targets
|
|
228
|
+
val timeSinceLastTarget = (frameTime - state.lastWobbleTargetUpdate) / 1_000_000_000.0
|
|
229
|
+
if (timeSinceLastTarget > wobbleUpdateInterval || state.wobbleTargets.all { it == 0.0 }) {
|
|
230
|
+
state.lastWobbleTargetUpdate = frameTime
|
|
231
|
+
val intensity = state.wobbleIntensity * config.wobbleIntensity
|
|
232
|
+
val maxOffset = 0.08 * intensity // Max 8% radius variation at full intensity
|
|
233
|
+
|
|
234
|
+
for (i in 0 until config.pointCount) {
|
|
235
|
+
state.wobbleTargets[i] = Random.nextDouble(-maxOffset, maxOffset)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Interpolate offsets toward targets
|
|
240
|
+
val interpolationSpeed = 4.0
|
|
241
|
+
val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
|
|
242
|
+
val factor = min(1.0, dt * interpolationSpeed)
|
|
243
|
+
|
|
244
|
+
for (i in 0 until min(state.wobbleOffsets.size, state.wobbleTargets.size)) {
|
|
245
|
+
state.wobbleOffsets[i] = state.wobbleOffsets[i] + (state.wobbleTargets[i] - state.wobbleOffsets[i]) * factor
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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 currentPhase: BreathPhase = BreathPhase.IDLE
|
|
58
|
+
|
|
59
|
+
@Volatile
|
|
60
|
+
var currentLabel: String = ""
|
|
61
|
+
|
|
62
|
+
@Volatile
|
|
63
|
+
var phaseProgress: Double = 0.0
|
|
64
|
+
|
|
65
|
+
// Wobble animation
|
|
66
|
+
@Volatile
|
|
67
|
+
var wobbleIntensity: Double = 1.0
|
|
68
|
+
|
|
69
|
+
@Volatile
|
|
70
|
+
var wobbleOffsets: MutableList<Double> = mutableListOf()
|
|
71
|
+
|
|
72
|
+
@Volatile
|
|
73
|
+
var wobbleTargets: MutableList<Double> = mutableListOf()
|
|
74
|
+
|
|
75
|
+
@Volatile
|
|
76
|
+
var lastWobbleUpdate: Long = System.nanoTime()
|
|
77
|
+
|
|
78
|
+
@Volatile
|
|
79
|
+
var lastWobbleTargetUpdate: Long = System.nanoTime()
|
|
80
|
+
|
|
81
|
+
// Exercise tracking
|
|
82
|
+
@Volatile
|
|
83
|
+
var exerciseStartTime: Long = System.nanoTime()
|
|
84
|
+
|
|
85
|
+
@Volatile
|
|
86
|
+
var totalDuration: Double = 0.0
|
|
87
|
+
|
|
88
|
+
// Callbacks
|
|
89
|
+
var onPhaseChange: ((BreathPhase, String, Int, Int) -> Unit)? = null
|
|
90
|
+
var onExerciseComplete: ((Int, Double) -> Unit)? = null
|
|
91
|
+
|
|
92
|
+
fun reset() {
|
|
93
|
+
state = BreathingExerciseState.STOPPED
|
|
94
|
+
currentPhaseIndex = 0
|
|
95
|
+
currentCycle = 0
|
|
96
|
+
currentScale = 1.0
|
|
97
|
+
targetScale = 1.0
|
|
98
|
+
currentPhase = BreathPhase.IDLE
|
|
99
|
+
currentLabel = ""
|
|
100
|
+
phaseProgress = 0.0
|
|
101
|
+
wobbleIntensity = 1.0
|
|
102
|
+
pauseTime = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import androidx.compose.animation.AnimatedContent
|
|
4
|
+
import androidx.compose.animation.fadeIn
|
|
5
|
+
import androidx.compose.animation.fadeOut
|
|
6
|
+
import androidx.compose.animation.togetherWith
|
|
7
|
+
import androidx.compose.foundation.layout.Box
|
|
8
|
+
import androidx.compose.material3.Text
|
|
9
|
+
import androidx.compose.runtime.Composable
|
|
10
|
+
import androidx.compose.ui.Alignment
|
|
11
|
+
import androidx.compose.ui.Modifier
|
|
12
|
+
import androidx.compose.ui.graphics.Color
|
|
13
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
14
|
+
import androidx.compose.ui.unit.sp
|
|
15
|
+
|
|
16
|
+
@Composable
|
|
17
|
+
fun BreathingTextCue(
|
|
18
|
+
text: String,
|
|
19
|
+
color: Color,
|
|
20
|
+
modifier: Modifier = Modifier
|
|
21
|
+
) {
|
|
22
|
+
Box(
|
|
23
|
+
modifier = modifier,
|
|
24
|
+
contentAlignment = Alignment.Center
|
|
25
|
+
) {
|
|
26
|
+
AnimatedContent(
|
|
27
|
+
targetState = text,
|
|
28
|
+
transitionSpec = {
|
|
29
|
+
fadeIn() togetherWith fadeOut()
|
|
30
|
+
},
|
|
31
|
+
label = "BreathingTextCue"
|
|
32
|
+
) { currentText ->
|
|
33
|
+
Text(
|
|
34
|
+
text = currentText,
|
|
35
|
+
color = color,
|
|
36
|
+
fontSize = 24.sp,
|
|
37
|
+
fontWeight = FontWeight.Medium
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import android.graphics.Color as AndroidColor
|
|
4
|
+
import expo.modules.kotlin.modules.Module
|
|
5
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
6
|
+
|
|
7
|
+
class ExpoBreathingExerciseModule : Module() {
|
|
8
|
+
override fun definition() = ModuleDefinition {
|
|
9
|
+
Name("ExpoBreathingExercise")
|
|
10
|
+
|
|
11
|
+
Events("onPhaseChange", "onExerciseComplete")
|
|
12
|
+
|
|
13
|
+
Function("startBreathingExercise") { pattern: Map<String, Any?> ->
|
|
14
|
+
startExercise(pattern)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Function("stopBreathingExercise") {
|
|
18
|
+
BreathingSharedState.reset()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Function("pauseBreathingExercise") {
|
|
22
|
+
val state = BreathingSharedState
|
|
23
|
+
if (state.state == BreathingExerciseState.RUNNING) {
|
|
24
|
+
state.state = BreathingExerciseState.PAUSED
|
|
25
|
+
state.pauseTime = System.nanoTime()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Function("resumeBreathingExercise") {
|
|
30
|
+
val state = BreathingSharedState
|
|
31
|
+
val pauseTime = state.pauseTime
|
|
32
|
+
if (state.state == BreathingExerciseState.PAUSED && pauseTime != null) {
|
|
33
|
+
val pauseDuration = System.nanoTime() - pauseTime
|
|
34
|
+
state.phaseStartTime += pauseDuration
|
|
35
|
+
state.exerciseStartTime += pauseDuration
|
|
36
|
+
state.state = BreathingExerciseState.RUNNING
|
|
37
|
+
state.pauseTime = null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
View(ExpoBreathingExerciseView::class) {
|
|
42
|
+
Events("onPhaseChange", "onExerciseComplete")
|
|
43
|
+
|
|
44
|
+
Prop("blobColors") { view: ExpoBreathingExerciseView, colors: List<Any> ->
|
|
45
|
+
val parsedColors = colors.map { parseColor(it) }
|
|
46
|
+
view.setBlobColors(parsedColors)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Prop("innerBlobColor") { view: ExpoBreathingExerciseView, color: Any ->
|
|
50
|
+
view.setInnerBlobColor(parseColor(color))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Prop("progressRingColor") { view: ExpoBreathingExerciseView, color: Any ->
|
|
54
|
+
view.setProgressRingColor(parseColor(color))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Prop("textColor") { view: ExpoBreathingExerciseView, color: Any ->
|
|
58
|
+
view.setTextColor(parseColor(color))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Prop("showProgressRing") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
62
|
+
view.setShowProgressRing(value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Prop("showTextCue") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
66
|
+
view.setShowTextCue(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Prop("showInnerBlob") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
70
|
+
view.setShowInnerBlob(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Prop("showShadow") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
74
|
+
view.setShowShadow(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Prop("pointCount") { view: ExpoBreathingExerciseView, value: Int ->
|
|
78
|
+
view.setPointCount(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Prop("wobbleIntensity") { view: ExpoBreathingExerciseView, value: Double ->
|
|
82
|
+
view.setWobbleIntensity(value)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private fun startExercise(pattern: Map<String, Any?>) {
|
|
88
|
+
val state = BreathingSharedState
|
|
89
|
+
state.reset()
|
|
90
|
+
|
|
91
|
+
// Parse phases
|
|
92
|
+
@Suppress("UNCHECKED_CAST")
|
|
93
|
+
val phasesArray = pattern["phases"] as? List<Map<String, Any?>> ?: return
|
|
94
|
+
|
|
95
|
+
val phases = phasesArray.mapNotNull { phaseDict ->
|
|
96
|
+
val phaseString = phaseDict["phase"] as? String ?: return@mapNotNull null
|
|
97
|
+
val duration = (phaseDict["duration"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
98
|
+
val targetScale = (phaseDict["targetScale"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
99
|
+
val label = phaseDict["label"] as? String ?: return@mapNotNull null
|
|
100
|
+
|
|
101
|
+
val phase = when (phaseString) {
|
|
102
|
+
"inhale" -> BreathPhase.INHALE
|
|
103
|
+
"holdIn" -> BreathPhase.HOLD_IN
|
|
104
|
+
"exhale" -> BreathPhase.EXHALE
|
|
105
|
+
"holdOut" -> BreathPhase.HOLD_OUT
|
|
106
|
+
else -> return@mapNotNull null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
BreathPhaseConfig(
|
|
110
|
+
phase = phase,
|
|
111
|
+
duration = duration / 1000.0, // Convert ms to seconds
|
|
112
|
+
targetScale = targetScale,
|
|
113
|
+
label = label
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (phases.isEmpty()) return
|
|
118
|
+
|
|
119
|
+
// Parse cycles
|
|
120
|
+
state.totalCycles = (pattern["cycles"] as? Number)?.toInt()
|
|
121
|
+
|
|
122
|
+
// Setup state
|
|
123
|
+
state.phases = phases
|
|
124
|
+
state.currentPhaseIndex = 0
|
|
125
|
+
state.currentCycle = 0
|
|
126
|
+
state.phaseStartTime = System.nanoTime()
|
|
127
|
+
state.exerciseStartTime = System.nanoTime()
|
|
128
|
+
|
|
129
|
+
// Set initial phase values
|
|
130
|
+
val firstPhase = phases[0]
|
|
131
|
+
state.currentPhase = firstPhase.phase
|
|
132
|
+
state.currentLabel = firstPhase.label
|
|
133
|
+
state.targetScale = firstPhase.targetScale
|
|
134
|
+
state.currentScale = 1.0
|
|
135
|
+
state.phaseProgress = 0.0
|
|
136
|
+
|
|
137
|
+
// Set initial wobble intensity
|
|
138
|
+
state.wobbleIntensity = when (firstPhase.phase) {
|
|
139
|
+
BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
|
|
140
|
+
BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
|
|
141
|
+
BreathPhase.IDLE -> 0.5
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
state.state = BreathingExerciseState.RUNNING
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun parseColor(value: Any): Int {
|
|
148
|
+
return when (value) {
|
|
149
|
+
is String -> AndroidColor.parseColor(value)
|
|
150
|
+
is Int -> value
|
|
151
|
+
is Double -> value.toInt()
|
|
152
|
+
is Long -> value.toInt()
|
|
153
|
+
else -> AndroidColor.WHITE
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.compose.foundation.layout.Box
|
|
5
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
6
|
+
import androidx.compose.foundation.layout.padding
|
|
7
|
+
import androidx.compose.runtime.Composable
|
|
8
|
+
import androidx.compose.runtime.getValue
|
|
9
|
+
import androidx.compose.runtime.mutableStateOf
|
|
10
|
+
import androidx.compose.runtime.setValue
|
|
11
|
+
import androidx.compose.ui.Alignment
|
|
12
|
+
import androidx.compose.ui.Modifier
|
|
13
|
+
import androidx.compose.ui.graphics.Color
|
|
14
|
+
import androidx.compose.ui.platform.ComposeView
|
|
15
|
+
import androidx.compose.ui.unit.dp
|
|
16
|
+
import expo.modules.kotlin.AppContext
|
|
17
|
+
import expo.modules.kotlin.views.ExpoView
|
|
18
|
+
|
|
19
|
+
class ExpoBreathingExerciseView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
20
|
+
|
|
21
|
+
// Mutable state for configuration
|
|
22
|
+
private var config by mutableStateOf(BreathingConfiguration())
|
|
23
|
+
|
|
24
|
+
private val onPhaseChange by EventDispatcher()
|
|
25
|
+
private val onExerciseComplete by EventDispatcher()
|
|
26
|
+
|
|
27
|
+
private val composeView = ComposeView(context).apply {
|
|
28
|
+
setContent {
|
|
29
|
+
BreathingExerciseViewContent()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
init {
|
|
34
|
+
// Allow effects to render outside view bounds
|
|
35
|
+
clipChildren = false
|
|
36
|
+
clipToPadding = false
|
|
37
|
+
composeView.clipChildren = false
|
|
38
|
+
composeView.clipToPadding = false
|
|
39
|
+
addView(composeView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
40
|
+
|
|
41
|
+
// Setup callbacks
|
|
42
|
+
setupCallbacks()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private fun setupCallbacks() {
|
|
46
|
+
BreathingSharedState.onPhaseChange = { phase, label, phaseIndex, cycle ->
|
|
47
|
+
onPhaseChange(
|
|
48
|
+
mapOf(
|
|
49
|
+
"phase" to phase.value,
|
|
50
|
+
"label" to label,
|
|
51
|
+
"phaseIndex" to phaseIndex,
|
|
52
|
+
"cycle" to cycle
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
BreathingSharedState.onExerciseComplete = { totalCycles, totalDuration ->
|
|
58
|
+
onExerciseComplete(
|
|
59
|
+
mapOf(
|
|
60
|
+
"totalCycles" to totalCycles,
|
|
61
|
+
"totalDuration" to totalDuration
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Composable
|
|
68
|
+
private fun BreathingExerciseViewContent() {
|
|
69
|
+
Box(
|
|
70
|
+
modifier = Modifier
|
|
71
|
+
.fillMaxSize()
|
|
72
|
+
.padding(24.dp),
|
|
73
|
+
contentAlignment = Alignment.Center
|
|
74
|
+
) {
|
|
75
|
+
BreathingExerciseView(
|
|
76
|
+
config = config,
|
|
77
|
+
modifier = Modifier.fillMaxSize()
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun setBlobColors(colors: List<Int>) {
|
|
83
|
+
config = config.copy(
|
|
84
|
+
blobColors = colors.map { Color(it) }
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fun setInnerBlobColor(color: Int) {
|
|
89
|
+
config = config.copy(innerBlobColor = Color(color))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fun setProgressRingColor(color: Int) {
|
|
93
|
+
config = config.copy(progressRingColor = Color(color))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun setTextColor(color: Int) {
|
|
97
|
+
config = config.copy(textColor = Color(color))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fun setShowProgressRing(value: Boolean) {
|
|
101
|
+
config = config.copy(showProgressRing = value)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun setShowTextCue(value: Boolean) {
|
|
105
|
+
config = config.copy(showTextCue = value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fun setShowInnerBlob(value: Boolean) {
|
|
109
|
+
config = config.copy(showInnerBlob = value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fun setShowShadow(value: Boolean) {
|
|
113
|
+
config = config.copy(showShadow = value)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fun setPointCount(value: Int) {
|
|
117
|
+
config = config.copy(pointCount = maxOf(6, value))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fun setWobbleIntensity(value: Double) {
|
|
121
|
+
config = config.copy(wobbleIntensity = value.coerceIn(0.0, 1.0))
|
|
122
|
+
}
|
|
123
|
+
}
|