expo-orb 0.1.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/.eslintrc.js +5 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/android/build.gradle +60 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/orb/ExpoOrbModule.kt +79 -0
- package/android/src/main/java/expo/modules/orb/ExpoOrbView.kt +105 -0
- package/android/src/main/java/expo/modules/orb/OrbConfiguration.kt +30 -0
- package/android/src/main/java/expo/modules/orb/OrbSharedState.kt +25 -0
- package/android/src/main/java/expo/modules/orb/OrbView.kt +368 -0
- package/android/src/main/java/expo/modules/orb/ParticlesView.kt +77 -0
- package/android/src/main/java/expo/modules/orb/RotatingGlowView.kt +90 -0
- package/android/src/main/java/expo/modules/orb/WavyBlobView.kt +90 -0
- package/build/ExpoOrb.types.d.ts +17 -0
- package/build/ExpoOrb.types.d.ts.map +1 -0
- package/build/ExpoOrb.types.js +2 -0
- package/build/ExpoOrb.types.js.map +1 -0
- package/build/ExpoOrbModule.d.ts +17 -0
- package/build/ExpoOrbModule.d.ts.map +1 -0
- package/build/ExpoOrbModule.js +11 -0
- package/build/ExpoOrbModule.js.map +1 -0
- package/build/ExpoOrbModule.web.d.ts +6 -0
- package/build/ExpoOrbModule.web.d.ts.map +1 -0
- package/build/ExpoOrbModule.web.js +5 -0
- package/build/ExpoOrbModule.web.js.map +1 -0
- package/build/ExpoOrbView.d.ts +4 -0
- package/build/ExpoOrbView.d.ts.map +1 -0
- package/build/ExpoOrbView.js +7 -0
- package/build/ExpoOrbView.js.map +1 -0
- package/build/ExpoOrbView.web.d.ts +4 -0
- package/build/ExpoOrbView.web.d.ts.map +1 -0
- package/build/ExpoOrbView.web.js +17 -0
- package/build/ExpoOrbView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoOrb.podspec +30 -0
- package/ios/ExpoOrbModule.swift +70 -0
- package/ios/ExpoOrbView.swift +105 -0
- package/ios/Orb/OrbConfiguration.swift +53 -0
- package/ios/Orb/OrbView.swift +367 -0
- package/ios/Orb/ParticlesView.swift +156 -0
- package/ios/Orb/RealisticShadows.swift +42 -0
- package/ios/Orb/RotatingGlowView.swift +62 -0
- package/ios/Orb/WavyBlobView.swift +83 -0
- package/package.json +46 -0
- package/src/ExpoOrb.types.ts +17 -0
- package/src/ExpoOrbModule.ts +22 -0
- package/src/ExpoOrbModule.web.ts +5 -0
- package/src/ExpoOrbView.tsx +11 -0
- package/src/ExpoOrbView.web.tsx +26 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
package expo.modules.orb
|
|
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.foundation.shape.CircleShape
|
|
11
|
+
import androidx.compose.runtime.Composable
|
|
12
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
13
|
+
import androidx.compose.runtime.getValue
|
|
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.graphicsLayer
|
|
28
|
+
import androidx.compose.ui.unit.dp
|
|
29
|
+
import kotlin.math.PI
|
|
30
|
+
import kotlin.math.abs
|
|
31
|
+
import kotlin.math.max
|
|
32
|
+
import kotlin.math.min
|
|
33
|
+
import kotlin.math.pow
|
|
34
|
+
import kotlin.math.sin
|
|
35
|
+
|
|
36
|
+
@Composable
|
|
37
|
+
fun OrbView(
|
|
38
|
+
config: OrbConfiguration,
|
|
39
|
+
useSharedActivityState: Boolean = false,
|
|
40
|
+
modifier: Modifier = Modifier
|
|
41
|
+
) {
|
|
42
|
+
// Frame-based animation state
|
|
43
|
+
var elapsedTime by remember { mutableStateOf(0.0) }
|
|
44
|
+
var frameTime by remember { mutableStateOf(System.nanoTime()) }
|
|
45
|
+
|
|
46
|
+
// Continuous animation loop
|
|
47
|
+
LaunchedEffect(Unit) {
|
|
48
|
+
val startTime = System.nanoTime()
|
|
49
|
+
while (true) {
|
|
50
|
+
withFrameNanos { currentTime ->
|
|
51
|
+
elapsedTime = (currentTime - startTime) / 1_000_000_000.0
|
|
52
|
+
frameTime = currentTime
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Interpolate activity
|
|
58
|
+
val activity = if (useSharedActivityState) {
|
|
59
|
+
interpolatedActivity(frameTime)
|
|
60
|
+
} else {
|
|
61
|
+
0.0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Compute effective config from activity
|
|
65
|
+
val effectiveConfig = activityDerivedConfig(activity, config)
|
|
66
|
+
|
|
67
|
+
// Calculate breathing scale
|
|
68
|
+
val scale = breathingScale(frameTime, effectiveConfig)
|
|
69
|
+
|
|
70
|
+
// Outer container with padding to allow glow/shadow overflow
|
|
71
|
+
Box(
|
|
72
|
+
modifier = modifier
|
|
73
|
+
.graphicsLayer {
|
|
74
|
+
// Disable clipping to allow glow/shadow overflow
|
|
75
|
+
clip = false
|
|
76
|
+
}
|
|
77
|
+
.aspectRatio(1f)
|
|
78
|
+
.scale(scale),
|
|
79
|
+
contentAlignment = Alignment.Center
|
|
80
|
+
) {
|
|
81
|
+
// Shadow layers (rendered outside the circle)
|
|
82
|
+
if (config.showShadow) {
|
|
83
|
+
RealisticShadow(
|
|
84
|
+
colors = config.backgroundColors,
|
|
85
|
+
modifier = Modifier
|
|
86
|
+
.fillMaxSize()
|
|
87
|
+
.graphicsLayer { clip = false }
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Main orb content clipped to circle
|
|
92
|
+
Box(
|
|
93
|
+
modifier = Modifier
|
|
94
|
+
.fillMaxSize()
|
|
95
|
+
.clip(CircleShape)
|
|
96
|
+
) {
|
|
97
|
+
// Background gradient
|
|
98
|
+
if (config.showBackground) {
|
|
99
|
+
Box(
|
|
100
|
+
modifier = Modifier
|
|
101
|
+
.fillMaxSize()
|
|
102
|
+
.background(
|
|
103
|
+
brush = Brush.verticalGradient(
|
|
104
|
+
colors = config.backgroundColors.reversed()
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Base depth glows
|
|
111
|
+
BaseDepthGlows(
|
|
112
|
+
effectiveConfig = effectiveConfig,
|
|
113
|
+
elapsedTime = elapsedTime,
|
|
114
|
+
modifier = Modifier.fillMaxSize()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// Wavy blobs
|
|
118
|
+
if (config.showWavyBlobs) {
|
|
119
|
+
WavyBlobLayer(
|
|
120
|
+
elapsedTime = elapsedTime,
|
|
121
|
+
modifier = Modifier.fillMaxSize()
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Core glow effects
|
|
126
|
+
if (config.showGlowEffects) {
|
|
127
|
+
CoreGlowEffects(
|
|
128
|
+
effectiveConfig = effectiveConfig,
|
|
129
|
+
elapsedTime = elapsedTime,
|
|
130
|
+
modifier = Modifier
|
|
131
|
+
.fillMaxSize()
|
|
132
|
+
.padding(8.dp)
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Particles
|
|
137
|
+
if (config.showParticles) {
|
|
138
|
+
ParticlesView(
|
|
139
|
+
color = config.particleColor,
|
|
140
|
+
particleCount = 10,
|
|
141
|
+
speedRange = 10f..20f,
|
|
142
|
+
sizeRange = 0.5f..1f,
|
|
143
|
+
opacityRange = 0f..0.3f,
|
|
144
|
+
elapsedTime = elapsedTime,
|
|
145
|
+
modifier = Modifier.fillMaxSize()
|
|
146
|
+
)
|
|
147
|
+
ParticlesView(
|
|
148
|
+
color = config.particleColor,
|
|
149
|
+
particleCount = 10,
|
|
150
|
+
speedRange = 20f..30f,
|
|
151
|
+
sizeRange = 0.2f..1f,
|
|
152
|
+
opacityRange = 0.3f..0.8f,
|
|
153
|
+
elapsedTime = elapsedTime,
|
|
154
|
+
modifier = Modifier.fillMaxSize()
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Inner glow overlay
|
|
159
|
+
InnerGlowOverlay(
|
|
160
|
+
modifier = Modifier.fillMaxSize()
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fun interpolatedActivity(currentTime: Long): Double {
|
|
167
|
+
val state = OrbSharedState
|
|
168
|
+
val target = state.targetActivity
|
|
169
|
+
val dt = (currentTime - state.lastUpdateTime) / 1_000_000_000.0 // Convert to seconds
|
|
170
|
+
state.lastUpdateTime = currentTime
|
|
171
|
+
|
|
172
|
+
// Smooth interpolation (same as iOS)
|
|
173
|
+
val factor = min(1.0, dt * 6.0)
|
|
174
|
+
val next = state.currentActivity + (target - state.currentActivity) * factor
|
|
175
|
+
state.currentActivity = next
|
|
176
|
+
|
|
177
|
+
return next
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun activityDerivedConfig(activity: Double, config: OrbConfiguration): EffectiveConfig {
|
|
181
|
+
// IMPORTANT: Rotation speed must stay CONSTANT
|
|
182
|
+
val speed = 18.0
|
|
183
|
+
// Breathing speed CAN vary because we use cumulative phase
|
|
184
|
+
val breathingSpeed = 0.03 + activity * 0.25
|
|
185
|
+
// Idle: no breathing, Speaking: full breathing
|
|
186
|
+
val breathingIntensity = max(0.0, (activity - 0.2)) * 1.25
|
|
187
|
+
// Idle: barely visible glow, Speaking: bright
|
|
188
|
+
val coreGlowIntensity = 0.08 + activity * 1.8
|
|
189
|
+
|
|
190
|
+
return EffectiveConfig(
|
|
191
|
+
speed = speed,
|
|
192
|
+
breathingIntensity = breathingIntensity,
|
|
193
|
+
breathingSpeed = breathingSpeed,
|
|
194
|
+
coreGlowIntensity = coreGlowIntensity,
|
|
195
|
+
glowColor = config.glowColor
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun breathingScale(currentTime: Long, effectiveConfig: EffectiveConfig): Float {
|
|
200
|
+
val intensity = max(0.0, min(1.0, effectiveConfig.breathingIntensity))
|
|
201
|
+
if (intensity == 0.0) {
|
|
202
|
+
return 1f
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
val state = OrbSharedState
|
|
206
|
+
val dt = (currentTime - state.lastBreathingUpdate) / 1_000_000_000.0
|
|
207
|
+
state.lastBreathingUpdate = currentTime
|
|
208
|
+
|
|
209
|
+
val speed = max(0.01, effectiveConfig.breathingSpeed)
|
|
210
|
+
state.breathingPhase += dt * speed * 2 * PI
|
|
211
|
+
|
|
212
|
+
// Punchy wave shape
|
|
213
|
+
val rawWave = sin(state.breathingPhase)
|
|
214
|
+
val wave = if (rawWave >= 0) {
|
|
215
|
+
rawWave.pow(0.6)
|
|
216
|
+
} else {
|
|
217
|
+
-abs(rawWave).pow(1.4)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
val amplitude = intensity * 0.17
|
|
221
|
+
return (1.0 + amplitude * wave).toFloat()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Composable
|
|
225
|
+
private fun BaseDepthGlows(
|
|
226
|
+
effectiveConfig: EffectiveConfig,
|
|
227
|
+
elapsedTime: Double,
|
|
228
|
+
modifier: Modifier = Modifier
|
|
229
|
+
) {
|
|
230
|
+
Box(modifier = modifier) {
|
|
231
|
+
SimpleRotatingGlow(
|
|
232
|
+
color = effectiveConfig.glowColor,
|
|
233
|
+
rotationSpeed = effectiveConfig.speed * 0.75,
|
|
234
|
+
direction = RotationDirection.COUNTER_CLOCKWISE,
|
|
235
|
+
elapsedTime = elapsedTime,
|
|
236
|
+
alpha = 0.5f,
|
|
237
|
+
modifier = Modifier
|
|
238
|
+
.fillMaxSize()
|
|
239
|
+
.padding(4.dp)
|
|
240
|
+
.blur(8.dp)
|
|
241
|
+
)
|
|
242
|
+
SimpleRotatingGlow(
|
|
243
|
+
color = effectiveConfig.glowColor.copy(alpha = 0.5f),
|
|
244
|
+
rotationSpeed = effectiveConfig.speed * 0.25,
|
|
245
|
+
direction = RotationDirection.CLOCKWISE,
|
|
246
|
+
elapsedTime = elapsedTime,
|
|
247
|
+
alpha = 0.3f,
|
|
248
|
+
modifier = Modifier
|
|
249
|
+
.fillMaxSize()
|
|
250
|
+
.padding(8.dp)
|
|
251
|
+
.blur(4.dp)
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@Composable
|
|
257
|
+
private fun CoreGlowEffects(
|
|
258
|
+
effectiveConfig: EffectiveConfig,
|
|
259
|
+
elapsedTime: Double,
|
|
260
|
+
modifier: Modifier = Modifier
|
|
261
|
+
) {
|
|
262
|
+
Box(modifier = modifier) {
|
|
263
|
+
SimpleRotatingGlow(
|
|
264
|
+
color = effectiveConfig.glowColor,
|
|
265
|
+
rotationSpeed = effectiveConfig.speed * 1.2,
|
|
266
|
+
direction = RotationDirection.CLOCKWISE,
|
|
267
|
+
elapsedTime = elapsedTime,
|
|
268
|
+
alpha = (effectiveConfig.coreGlowIntensity * 0.8).toFloat(),
|
|
269
|
+
modifier = Modifier
|
|
270
|
+
.fillMaxSize()
|
|
271
|
+
.blur(12.dp)
|
|
272
|
+
)
|
|
273
|
+
SimpleRotatingGlow(
|
|
274
|
+
color = effectiveConfig.glowColor,
|
|
275
|
+
rotationSpeed = effectiveConfig.speed * 0.9,
|
|
276
|
+
direction = RotationDirection.CLOCKWISE,
|
|
277
|
+
elapsedTime = elapsedTime,
|
|
278
|
+
alpha = (effectiveConfig.coreGlowIntensity * 0.6).toFloat(),
|
|
279
|
+
modifier = Modifier
|
|
280
|
+
.fillMaxSize()
|
|
281
|
+
.blur(8.dp)
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@Composable
|
|
287
|
+
private fun WavyBlobLayer(
|
|
288
|
+
elapsedTime: Double,
|
|
289
|
+
modifier: Modifier = Modifier
|
|
290
|
+
) {
|
|
291
|
+
val blobSpeed = 20.0
|
|
292
|
+
|
|
293
|
+
Box(modifier = modifier) {
|
|
294
|
+
WavyBlobView(
|
|
295
|
+
color = Color.White.copy(alpha = 0.4f),
|
|
296
|
+
loopDuration = 60.0 / blobSpeed * 1.75,
|
|
297
|
+
elapsedTime = elapsedTime,
|
|
298
|
+
modifier = Modifier
|
|
299
|
+
.fillMaxSize()
|
|
300
|
+
.scale(1.5f)
|
|
301
|
+
.offset(y = 40.dp)
|
|
302
|
+
.blur(2.dp)
|
|
303
|
+
)
|
|
304
|
+
WavyBlobView(
|
|
305
|
+
color = Color.White.copy(alpha = 0.25f),
|
|
306
|
+
loopDuration = 60.0 / blobSpeed * 2.25,
|
|
307
|
+
elapsedTime = elapsedTime,
|
|
308
|
+
modifier = Modifier
|
|
309
|
+
.fillMaxSize()
|
|
310
|
+
.scale(1.2f)
|
|
311
|
+
.offset(y = (-40).dp)
|
|
312
|
+
.graphicsLayer { rotationZ = 90f }
|
|
313
|
+
.blur(2.dp)
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@Composable
|
|
319
|
+
private fun InnerGlowOverlay(modifier: Modifier = Modifier) {
|
|
320
|
+
Canvas(modifier = modifier) {
|
|
321
|
+
val center = Offset(size.width / 2f, size.height / 2f)
|
|
322
|
+
val radius = min(size.width, size.height) / 2f
|
|
323
|
+
|
|
324
|
+
// Inner glow rings
|
|
325
|
+
drawCircle(
|
|
326
|
+
brush = Brush.verticalGradient(
|
|
327
|
+
colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.15f)),
|
|
328
|
+
),
|
|
329
|
+
radius = radius,
|
|
330
|
+
center = center
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
@Composable
|
|
336
|
+
private fun RealisticShadow(
|
|
337
|
+
colors: List<Color>,
|
|
338
|
+
modifier: Modifier = Modifier
|
|
339
|
+
) {
|
|
340
|
+
Box(modifier = modifier) {
|
|
341
|
+
// Soft outer shadow
|
|
342
|
+
Canvas(
|
|
343
|
+
modifier = Modifier
|
|
344
|
+
.fillMaxSize()
|
|
345
|
+
.offset(y = 8.dp)
|
|
346
|
+
.blur(24.dp)
|
|
347
|
+
.graphicsLayer { alpha = 0.3f }
|
|
348
|
+
) {
|
|
349
|
+
drawCircle(
|
|
350
|
+
brush = Brush.verticalGradient(colors.reversed()),
|
|
351
|
+
radius = size.minDimension / 2f
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
// Closer shadow
|
|
355
|
+
Canvas(
|
|
356
|
+
modifier = Modifier
|
|
357
|
+
.fillMaxSize()
|
|
358
|
+
.offset(y = 4.dp)
|
|
359
|
+
.blur(12.dp)
|
|
360
|
+
.graphicsLayer { alpha = 0.5f }
|
|
361
|
+
) {
|
|
362
|
+
drawCircle(
|
|
363
|
+
brush = Brush.verticalGradient(colors.reversed()),
|
|
364
|
+
radius = size.minDimension / 2f
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package expo.modules.orb
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.Canvas
|
|
4
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
|
+
import androidx.compose.runtime.Composable
|
|
6
|
+
import androidx.compose.runtime.remember
|
|
7
|
+
import androidx.compose.ui.Modifier
|
|
8
|
+
import androidx.compose.ui.geometry.Offset
|
|
9
|
+
import androidx.compose.ui.graphics.BlendMode
|
|
10
|
+
import androidx.compose.ui.graphics.Color
|
|
11
|
+
import kotlin.math.sin
|
|
12
|
+
import kotlin.random.Random
|
|
13
|
+
|
|
14
|
+
data class Particle(
|
|
15
|
+
val id: Int,
|
|
16
|
+
val startX: Float,
|
|
17
|
+
val startY: Float,
|
|
18
|
+
val speed: Float,
|
|
19
|
+
val size: Float,
|
|
20
|
+
val alpha: Float,
|
|
21
|
+
val phase: Float // Random phase offset for variety
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@Composable
|
|
25
|
+
fun ParticlesView(
|
|
26
|
+
color: Color,
|
|
27
|
+
particleCount: Int = 10,
|
|
28
|
+
speedRange: ClosedFloatingPointRange<Float> = 10f..20f,
|
|
29
|
+
sizeRange: ClosedFloatingPointRange<Float> = 0.5f..1f,
|
|
30
|
+
opacityRange: ClosedFloatingPointRange<Float> = 0f..0.3f,
|
|
31
|
+
elapsedTime: Double,
|
|
32
|
+
modifier: Modifier = Modifier
|
|
33
|
+
) {
|
|
34
|
+
// Generate stable particles on first composition
|
|
35
|
+
val particles = remember {
|
|
36
|
+
(0 until particleCount).map { id ->
|
|
37
|
+
Particle(
|
|
38
|
+
id = id,
|
|
39
|
+
startX = Random.nextFloat(),
|
|
40
|
+
startY = Random.nextFloat(),
|
|
41
|
+
speed = Random.nextFloat() * (speedRange.endInclusive - speedRange.start) + speedRange.start,
|
|
42
|
+
size = Random.nextFloat() * (sizeRange.endInclusive - sizeRange.start) + sizeRange.start,
|
|
43
|
+
alpha = Random.nextFloat() * (opacityRange.endInclusive - opacityRange.start) + opacityRange.start,
|
|
44
|
+
phase = Random.nextFloat() * 6.28f // Random phase 0-2π
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Canvas(modifier = modifier.fillMaxSize()) {
|
|
50
|
+
val width = size.width
|
|
51
|
+
val height = size.height
|
|
52
|
+
|
|
53
|
+
particles.forEach { particle ->
|
|
54
|
+
// Calculate particle position based on time
|
|
55
|
+
val cycleTime = 3.0 + particle.phase // 3-6 second cycle
|
|
56
|
+
val progress = ((elapsedTime + particle.phase) % cycleTime) / cycleTime
|
|
57
|
+
|
|
58
|
+
// Drift upward with slight horizontal wobble
|
|
59
|
+
val x = particle.startX * width + sin(elapsedTime * 0.5 + particle.phase) * 20f
|
|
60
|
+
val y = height * (1f - progress.toFloat()) * particle.startY + height * (1f - progress.toFloat())
|
|
61
|
+
|
|
62
|
+
// Fade in/out based on lifecycle
|
|
63
|
+
val lifecycleAlpha = when {
|
|
64
|
+
progress < 0.2 -> (progress / 0.2).toFloat() // Fade in
|
|
65
|
+
progress > 0.8 -> ((1.0 - progress) / 0.2).toFloat() // Fade out
|
|
66
|
+
else -> 1f
|
|
67
|
+
} * particle.alpha
|
|
68
|
+
|
|
69
|
+
drawCircle(
|
|
70
|
+
color = color.copy(alpha = lifecycleAlpha),
|
|
71
|
+
radius = particle.size * 12f,
|
|
72
|
+
center = Offset(x.toFloat(), y.toFloat()),
|
|
73
|
+
blendMode = BlendMode.Plus
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package expo.modules.orb
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.Canvas
|
|
4
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
|
+
import androidx.compose.runtime.Composable
|
|
6
|
+
import androidx.compose.ui.Modifier
|
|
7
|
+
import androidx.compose.ui.geometry.Offset
|
|
8
|
+
import androidx.compose.ui.graphics.BlendMode
|
|
9
|
+
import androidx.compose.ui.graphics.Color
|
|
10
|
+
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
11
|
+
import androidx.compose.ui.graphics.drawscope.rotate
|
|
12
|
+
import kotlin.math.min
|
|
13
|
+
|
|
14
|
+
enum class RotationDirection(val multiplier: Double) {
|
|
15
|
+
CLOCKWISE(1.0),
|
|
16
|
+
COUNTER_CLOCKWISE(-1.0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Composable
|
|
20
|
+
fun RotatingGlowView(
|
|
21
|
+
color: Color,
|
|
22
|
+
rotationSpeed: Double = 30.0,
|
|
23
|
+
direction: RotationDirection,
|
|
24
|
+
elapsedTime: Double,
|
|
25
|
+
modifier: Modifier = Modifier,
|
|
26
|
+
blurRadius: Float = 0f
|
|
27
|
+
) {
|
|
28
|
+
val safeSpeed = maxOf(0.01, rotationSpeed)
|
|
29
|
+
val rotation = (elapsedTime * safeSpeed * direction.multiplier).toFloat()
|
|
30
|
+
|
|
31
|
+
Canvas(modifier = modifier.fillMaxSize()) {
|
|
32
|
+
val sizePx = min(size.width, size.height)
|
|
33
|
+
val center = Offset(size.width / 2f, size.height / 2f)
|
|
34
|
+
|
|
35
|
+
rotate(rotation, pivot = center) {
|
|
36
|
+
// Main glow circle
|
|
37
|
+
drawCircle(
|
|
38
|
+
color = color,
|
|
39
|
+
radius = sizePx / 2f,
|
|
40
|
+
center = center,
|
|
41
|
+
alpha = 0.8f
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Cutout circle offset downward (creates crescent effect)
|
|
45
|
+
drawCircle(
|
|
46
|
+
color = Color.Transparent,
|
|
47
|
+
radius = sizePx * 0.655f, // 1.31 / 2
|
|
48
|
+
center = Offset(center.x, center.y + sizePx * 0.31f),
|
|
49
|
+
blendMode = BlendMode.Clear
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Simplified glow without complex masking - just a soft gradient circle
|
|
57
|
+
*/
|
|
58
|
+
@Composable
|
|
59
|
+
fun SimpleRotatingGlow(
|
|
60
|
+
color: Color,
|
|
61
|
+
rotationSpeed: Double = 30.0,
|
|
62
|
+
direction: RotationDirection,
|
|
63
|
+
elapsedTime: Double,
|
|
64
|
+
alpha: Float = 1f,
|
|
65
|
+
modifier: Modifier = Modifier
|
|
66
|
+
) {
|
|
67
|
+
val safeSpeed = maxOf(0.01, rotationSpeed)
|
|
68
|
+
val rotation = (elapsedTime * safeSpeed * direction.multiplier).toFloat()
|
|
69
|
+
// Clamp alpha to valid range 0-1
|
|
70
|
+
val safeAlpha = alpha.coerceIn(0f, 1f)
|
|
71
|
+
|
|
72
|
+
Canvas(modifier = modifier.fillMaxSize()) {
|
|
73
|
+
val sizePx = min(size.width, size.height)
|
|
74
|
+
val center = Offset(size.width / 2f, size.height / 2f)
|
|
75
|
+
|
|
76
|
+
rotate(rotation, pivot = center) {
|
|
77
|
+
// Draw offset glow to create rotating effect - subtle and smaller
|
|
78
|
+
drawCircle(
|
|
79
|
+
color = color.copy(alpha = (safeAlpha * 0.5f).coerceIn(0f, 1f)),
|
|
80
|
+
radius = sizePx * 0.25f,
|
|
81
|
+
center = Offset(center.x, center.y - sizePx * 0.12f)
|
|
82
|
+
)
|
|
83
|
+
drawCircle(
|
|
84
|
+
color = color.copy(alpha = (safeAlpha * 0.35f).coerceIn(0f, 1f)),
|
|
85
|
+
radius = sizePx * 0.18f,
|
|
86
|
+
center = Offset(center.x + sizePx * 0.08f, center.y + sizePx * 0.08f)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package expo.modules.orb
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.Canvas
|
|
4
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
|
+
import androidx.compose.runtime.Composable
|
|
6
|
+
import androidx.compose.ui.Modifier
|
|
7
|
+
import androidx.compose.ui.geometry.Offset
|
|
8
|
+
import androidx.compose.ui.graphics.Color
|
|
9
|
+
import androidx.compose.ui.graphics.Path
|
|
10
|
+
import kotlin.math.PI
|
|
11
|
+
import kotlin.math.atan2
|
|
12
|
+
import kotlin.math.cos
|
|
13
|
+
import kotlin.math.min
|
|
14
|
+
import kotlin.math.sin
|
|
15
|
+
|
|
16
|
+
data class BlobPoint(val x: Float, val y: Float)
|
|
17
|
+
|
|
18
|
+
@Composable
|
|
19
|
+
fun WavyBlobView(
|
|
20
|
+
color: Color,
|
|
21
|
+
loopDuration: Double = 1.0,
|
|
22
|
+
elapsedTime: Double,
|
|
23
|
+
modifier: Modifier = Modifier
|
|
24
|
+
) {
|
|
25
|
+
// Initial blob points (6 points in a circle)
|
|
26
|
+
val basePoints = (0 until 6).map { index ->
|
|
27
|
+
val angle = (index.toDouble() / 6.0) * 2.0 * PI
|
|
28
|
+
BlobPoint(
|
|
29
|
+
x = (0.5 + cos(angle) * 0.9).toFloat(),
|
|
30
|
+
y = (0.5 + sin(angle) * 0.9).toFloat()
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
val angle = ((elapsedTime % loopDuration) / loopDuration) * 2.0 * PI
|
|
35
|
+
|
|
36
|
+
Canvas(modifier = modifier.fillMaxSize()) {
|
|
37
|
+
val sizePx = min(size.width, size.height)
|
|
38
|
+
val center = Offset(size.width / 2f, size.height / 2f)
|
|
39
|
+
val radius = sizePx * 0.45f
|
|
40
|
+
|
|
41
|
+
// Adjust points with sine wave movement
|
|
42
|
+
val adjustedPoints = basePoints.mapIndexed { index, point ->
|
|
43
|
+
val phaseOffset = index * PI / 3.0
|
|
44
|
+
val xOffset = sin(angle + phaseOffset) * 0.15
|
|
45
|
+
val yOffset = cos(angle + phaseOffset) * 0.15
|
|
46
|
+
Offset(
|
|
47
|
+
x = ((point.x - 0.5f + xOffset.toFloat()) * radius + center.x).toFloat(),
|
|
48
|
+
y = ((point.y - 0.5f + yOffset.toFloat()) * radius + center.y).toFloat()
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
val path = Path().apply {
|
|
53
|
+
moveTo(adjustedPoints[0].x, adjustedPoints[0].y)
|
|
54
|
+
|
|
55
|
+
for (i in adjustedPoints.indices) {
|
|
56
|
+
val next = (i + 1) % adjustedPoints.size
|
|
57
|
+
|
|
58
|
+
// Calculate angles for control points
|
|
59
|
+
val currentAngle = atan2(
|
|
60
|
+
adjustedPoints[i].y - center.y,
|
|
61
|
+
adjustedPoints[i].x - center.x
|
|
62
|
+
)
|
|
63
|
+
val nextAngle = atan2(
|
|
64
|
+
adjustedPoints[next].y - center.y,
|
|
65
|
+
adjustedPoints[next].x - center.x
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
val handleLength = radius * 0.33f
|
|
69
|
+
|
|
70
|
+
val control1 = Offset(
|
|
71
|
+
x = adjustedPoints[i].x + cos(currentAngle + PI.toFloat() / 2f) * handleLength,
|
|
72
|
+
y = adjustedPoints[i].y + sin(currentAngle + PI.toFloat() / 2f) * handleLength
|
|
73
|
+
)
|
|
74
|
+
val control2 = Offset(
|
|
75
|
+
x = adjustedPoints[next].x + cos(nextAngle - PI.toFloat() / 2f) * handleLength,
|
|
76
|
+
y = adjustedPoints[next].y + sin(nextAngle - PI.toFloat() / 2f) * handleLength
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cubicTo(
|
|
80
|
+
control1.x, control1.y,
|
|
81
|
+
control2.x, control2.y,
|
|
82
|
+
adjustedPoints[next].x, adjustedPoints[next].y
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
close()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
drawPath(path = path, color = color)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
export type ExpoOrbViewProps = {
|
|
3
|
+
backgroundColors?: ColorValue[];
|
|
4
|
+
glowColor?: ColorValue;
|
|
5
|
+
particleColor?: ColorValue;
|
|
6
|
+
coreGlowIntensity?: number;
|
|
7
|
+
breathingIntensity?: number;
|
|
8
|
+
breathingSpeed?: number;
|
|
9
|
+
showBackground?: boolean;
|
|
10
|
+
showWavyBlobs?: boolean;
|
|
11
|
+
showParticles?: boolean;
|
|
12
|
+
showGlowEffects?: boolean;
|
|
13
|
+
showShadow?: boolean;
|
|
14
|
+
speed?: number;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=ExpoOrb.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoOrb.types.d.ts","sourceRoot":"","sources":["../src/ExpoOrb.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAErE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gBAAgB,CAAC,EAAE,UAAU,EAAE,CAAC;IAChC,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoOrb.types.js","sourceRoot":"","sources":["../src/ExpoOrb.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ColorValue, StyleProp, ViewStyle } from 'react-native';\n\nexport type ExpoOrbViewProps = {\n backgroundColors?: ColorValue[];\n glowColor?: ColorValue;\n particleColor?: ColorValue;\n coreGlowIntensity?: number;\n breathingIntensity?: number;\n breathingSpeed?: number;\n showBackground?: boolean;\n showWavyBlobs?: boolean;\n showParticles?: boolean;\n showGlowEffects?: boolean;\n showShadow?: boolean;\n speed?: number;\n style?: StyleProp<ViewStyle>;\n};\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
declare class ExpoOrbModule extends NativeModule {
|
|
3
|
+
/**
|
|
4
|
+
* Set the activity level (0-1) for the orb animation.
|
|
5
|
+
* Uses a native function instead of a prop to bypass React's reconciliation
|
|
6
|
+
* and prevent view re-renders that cause animation flickering.
|
|
7
|
+
*/
|
|
8
|
+
setActivity(activity: number): void;
|
|
9
|
+
}
|
|
10
|
+
declare const module: ExpoOrbModule;
|
|
11
|
+
export default module;
|
|
12
|
+
/**
|
|
13
|
+
* Set the orb activity level directly via native function.
|
|
14
|
+
* This bypasses React's prop system to prevent animation interference.
|
|
15
|
+
*/
|
|
16
|
+
export declare function setOrbActivity(activity: number): void;
|
|
17
|
+
//# sourceMappingURL=ExpoOrbModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoOrbModule.d.ts","sourceRoot":"","sources":["../src/ExpoOrbModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,OAAO,aAAc,SAAQ,YAAY;IAC9C;;;;OAIG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;CACpC;AAED,QAAA,MAAM,MAAM,eAAgD,CAAC;AAE7D,eAAe,MAAM,CAAC;AAEtB;;;GAGG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAErD"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { requireNativeModule } from 'expo';
|
|
2
|
+
const module = requireNativeModule('ExpoOrb');
|
|
3
|
+
export default module;
|
|
4
|
+
/**
|
|
5
|
+
* Set the orb activity level directly via native function.
|
|
6
|
+
* This bypasses React's prop system to prevent animation interference.
|
|
7
|
+
*/
|
|
8
|
+
export function setOrbActivity(activity) {
|
|
9
|
+
module.setActivity(activity);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=ExpoOrbModule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoOrbModule.js","sourceRoot":"","sources":["../src/ExpoOrbModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAWzD,MAAM,MAAM,GAAG,mBAAmB,CAAgB,SAAS,CAAC,CAAC;AAE7D,eAAe,MAAM,CAAC;AAEtB;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB;IAC7C,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;AAC/B,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\n\ndeclare class ExpoOrbModule extends NativeModule {\n /**\n * Set the activity level (0-1) for the orb animation.\n * Uses a native function instead of a prop to bypass React's reconciliation\n * and prevent view re-renders that cause animation flickering.\n */\n setActivity(activity: number): void;\n}\n\nconst module = requireNativeModule<ExpoOrbModule>('ExpoOrb');\n\nexport default module;\n\n/**\n * Set the orb activity level directly via native function.\n * This bypasses React's prop system to prevent animation interference.\n */\nexport function setOrbActivity(activity: number): void {\n module.setActivity(activity);\n}\n"]}
|