expo-orb 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -1
- package/android/build.gradle +2 -0
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +25 -0
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +610 -0
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +108 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +51 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +177 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +144 -0
- package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
- package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
- package/build/ExpoBreathingExercise.types.d.ts +48 -0
- package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
- package/build/ExpoBreathingExercise.types.js +2 -0
- package/build/ExpoBreathingExercise.types.js.map +1 -0
- package/build/ExpoBreathingExerciseModule.d.ts +16 -0
- package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseModule.js +52 -0
- package/build/ExpoBreathingExerciseModule.js.map +1 -0
- package/build/ExpoBreathingExerciseView.d.ts +4 -0
- package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseView.js +7 -0
- package/build/ExpoBreathingExerciseView.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +2 -2
- package/ios/Breathing/BreathingConfiguration.swift +57 -0
- package/ios/Breathing/BreathingExerciseView.swift +451 -0
- package/ios/Breathing/BreathingParticlesView.swift +81 -0
- package/ios/Breathing/BreathingSharedState.swift +84 -0
- package/ios/Breathing/BreathingTextCue.swift +14 -0
- package/ios/Breathing/MorphingBlobView.swift +242 -0
- package/ios/Breathing/ProgressRingView.swift +27 -0
- package/ios/ExpoBreathingExerciseModule.swift +182 -0
- package/ios/ExpoBreathingExerciseView.swift +124 -0
- package/package.json +8 -5
- package/src/ExpoBreathingExercise.types.ts +50 -0
- package/src/ExpoBreathingExerciseModule.ts +67 -0
- package/src/ExpoBreathingExerciseView.tsx +11 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExerciseModule.js","sourceRoot":"","sources":["../src/ExpoBreathingExerciseModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAUzD,MAAM,MAAM,GAAG,mBAAmB,CAAkC,uBAAuB,CAAC,CAAC;AAE7F,eAAe,MAAM,CAAC;AAEtB,MAAM,UAAU,sBAAsB,CAAC,OAAyB;IAC9D,MAAM,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,MAAM,CAAC,qBAAqB,EAAE,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,MAAM,CAAC,sBAAsB,EAAE,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,MAAM,CAAC,uBAAuB,EAAE,CAAC;AACnC,CAAC;AAED,MAAM,iBAAiB,GAA8C;IACnE,QAAQ,EAAE;QACR,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;YACrE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,CAAC;KACV;IACD,GAAG,EAAE;QACH,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;YACrE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;YAC5E,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;SACvE;QACD,MAAM,EAAE,CAAC;KACV;IACD,UAAU,EAAE;QACV,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,EAAE;KACX;IACD,OAAO,EAAE;QACP,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,CAAC;KACV;CACF,CAAC;AAEF,MAAM,UAAU,kBAAkB,CAAC,MAAuB;IACxD,OAAO,iBAAiB,CAAC,MAAM,CAAC,CAAC;AACnC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\nimport type { BreathingPattern, BreathingPreset } from './ExpoBreathingExercise.types';\n\ndeclare class ExpoBreathingExerciseModuleType extends NativeModule {\n startBreathingExercise(pattern: BreathingPattern): void;\n stopBreathingExercise(): void;\n pauseBreathingExercise(): void;\n resumeBreathingExercise(): void;\n}\n\nconst module = requireNativeModule<ExpoBreathingExerciseModuleType>('ExpoBreathingExercise');\n\nexport default module;\n\nexport function startBreathingExercise(pattern: BreathingPattern): void {\n module.startBreathingExercise(pattern);\n}\n\nexport function stopBreathingExercise(): void {\n module.stopBreathingExercise();\n}\n\nexport function pauseBreathingExercise(): void {\n module.pauseBreathingExercise();\n}\n\nexport function resumeBreathingExercise(): void {\n module.resumeBreathingExercise();\n}\n\nconst BREATHING_PRESETS: Record<BreathingPreset, BreathingPattern> = {\n relaxing: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'holdIn', duration: 7000, targetScale: 1.35, label: 'Hold' },\n { phase: 'exhale', duration: 8000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 4,\n },\n box: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'holdIn', duration: 4000, targetScale: 1.35, label: 'Hold' },\n { phase: 'exhale', duration: 4000, targetScale: 0.75, label: 'Breathe Out' },\n { phase: 'holdOut', duration: 4000, targetScale: 0.75, label: 'Hold' },\n ],\n cycles: 4,\n },\n energizing: {\n phases: [\n { phase: 'inhale', duration: 2000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'exhale', duration: 2000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 10,\n },\n calming: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'exhale', duration: 6000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 6,\n },\n};\n\nexport function getBreathingPreset(preset: BreathingPreset): BreathingPattern {\n return BREATHING_PRESETS[preset];\n}\n"]}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ExpoBreathingExerciseViewProps } from './ExpoBreathingExercise.types';
|
|
3
|
+
export default function ExpoBreathingExerciseView(props: ExpoBreathingExerciseViewProps): React.JSX.Element;
|
|
4
|
+
//# sourceMappingURL=ExpoBreathingExerciseView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExerciseView.d.ts","sourceRoot":"","sources":["../src/ExpoBreathingExerciseView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,8BAA8B,EAAE,MAAM,+BAA+B,CAAC;AAK/E,MAAM,CAAC,OAAO,UAAU,yBAAyB,CAAC,KAAK,EAAE,8BAA8B,qBAEtF"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
const NativeView = requireNativeView('ExpoBreathingExercise');
|
|
4
|
+
export default function ExpoBreathingExerciseView(props) {
|
|
5
|
+
return <NativeView {...props}/>;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=ExpoBreathingExerciseView.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExerciseView.js","sourceRoot":"","sources":["../src/ExpoBreathingExerciseView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACzC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,UAAU,GACd,iBAAiB,CAAC,uBAAuB,CAAC,CAAC;AAE7C,MAAM,CAAC,OAAO,UAAU,yBAAyB,CAAC,KAAqC;IACrF,OAAO,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,EAAG,CAAC;AACnC,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport * as React from 'react';\n\nimport { ExpoBreathingExerciseViewProps } from './ExpoBreathingExercise.types';\n\nconst NativeView: React.ComponentType<ExpoBreathingExerciseViewProps> =\n requireNativeView('ExpoBreathingExercise');\n\nexport default function ExpoBreathingExerciseView(props: ExpoBreathingExerciseViewProps) {\n return <NativeView {...props} />;\n}\n"]}
|
package/build/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { default, setOrbActivity } from './ExpoOrbModule';
|
|
2
2
|
export { default as ExpoOrbView } from './ExpoOrbView';
|
|
3
3
|
export * from './ExpoOrb.types';
|
|
4
|
+
export { default as ExpoBreathingExerciseModule, startBreathingExercise, stopBreathingExercise, pauseBreathingExercise, resumeBreathingExercise, getBreathingPreset, } from './ExpoBreathingExerciseModule';
|
|
5
|
+
export { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';
|
|
6
|
+
export * from './ExpoBreathingExercise.types';
|
|
4
7
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC;AAEjC,OAAO,EACL,OAAO,IAAI,2BAA2B,EACtC,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACnF,cAAc,+BAA+B,CAAC"}
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { default, setOrbActivity } from './ExpoOrbModule';
|
|
2
2
|
export { default as ExpoOrbView } from './ExpoOrbView';
|
|
3
3
|
export * from './ExpoOrb.types';
|
|
4
|
+
export { default as ExpoBreathingExerciseModule, startBreathingExercise, stopBreathingExercise, pauseBreathingExercise, resumeBreathingExercise, getBreathingPreset, } from './ExpoBreathingExerciseModule';
|
|
5
|
+
export { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';
|
|
6
|
+
export * from './ExpoBreathingExercise.types';
|
|
4
7
|
//# sourceMappingURL=index.js.map
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC","sourcesContent":["export { default, setOrbActivity } from './ExpoOrbModule';\nexport { default as ExpoOrbView } from './ExpoOrbView';\nexport * from './ExpoOrb.types';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC;AAEjC,OAAO,EACL,OAAO,IAAI,2BAA2B,EACtC,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACnF,cAAc,+BAA+B,CAAC","sourcesContent":["export { default, setOrbActivity } from './ExpoOrbModule';\nexport { default as ExpoOrbView } from './ExpoOrbView';\nexport * from './ExpoOrb.types';\n\nexport {\n default as ExpoBreathingExerciseModule,\n startBreathingExercise,\n stopBreathingExercise,\n pauseBreathingExercise,\n resumeBreathingExercise,\n getBreathingPreset,\n} from './ExpoBreathingExerciseModule';\nexport { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';\nexport * from './ExpoBreathingExercise.types';\n"]}
|
package/expo-module.config.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"platforms": ["apple", "android", "web"],
|
|
3
3
|
"apple": {
|
|
4
|
-
"modules": ["ExpoOrbModule"]
|
|
4
|
+
"modules": ["ExpoOrbModule", "ExpoBreathingExerciseModule"]
|
|
5
5
|
},
|
|
6
6
|
"android": {
|
|
7
|
-
"modules": ["expo.modules.orb.ExpoOrbModule"]
|
|
7
|
+
"modules": ["expo.modules.orb.ExpoOrbModule", "expo.modules.breathing.ExpoBreathingExerciseModule"]
|
|
8
8
|
}
|
|
9
9
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
public struct BreathingConfiguration {
|
|
4
|
+
var blobColors: [Color]
|
|
5
|
+
var innerBlobColor: Color
|
|
6
|
+
var glowColor: Color
|
|
7
|
+
var particleColor: Color
|
|
8
|
+
var progressRingColor: Color
|
|
9
|
+
var textColor: Color
|
|
10
|
+
var showProgressRing: Bool
|
|
11
|
+
var showTextCue: Bool
|
|
12
|
+
var showInnerBlob: Bool
|
|
13
|
+
var showShadow: Bool
|
|
14
|
+
var showParticles: Bool
|
|
15
|
+
var showWavyBlobs: Bool
|
|
16
|
+
var showGlowEffects: Bool
|
|
17
|
+
var pointCount: Int
|
|
18
|
+
var wobbleIntensity: Double
|
|
19
|
+
|
|
20
|
+
public init(
|
|
21
|
+
blobColors: [Color] = [
|
|
22
|
+
Color(red: 0.4, green: 0.7, blue: 0.9),
|
|
23
|
+
Color(red: 0.3, green: 0.5, blue: 0.8),
|
|
24
|
+
Color(red: 0.5, green: 0.3, blue: 0.7)
|
|
25
|
+
],
|
|
26
|
+
innerBlobColor: Color = Color.white.opacity(0.3),
|
|
27
|
+
glowColor: Color = .white,
|
|
28
|
+
particleColor: Color = .white,
|
|
29
|
+
progressRingColor: Color = Color.white.opacity(0.5),
|
|
30
|
+
textColor: Color = .white,
|
|
31
|
+
showProgressRing: Bool = true,
|
|
32
|
+
showTextCue: Bool = true,
|
|
33
|
+
showInnerBlob: Bool = true,
|
|
34
|
+
showShadow: Bool = true,
|
|
35
|
+
showParticles: Bool = true,
|
|
36
|
+
showWavyBlobs: Bool = true,
|
|
37
|
+
showGlowEffects: Bool = true,
|
|
38
|
+
pointCount: Int = 8,
|
|
39
|
+
wobbleIntensity: Double = 1.0
|
|
40
|
+
) {
|
|
41
|
+
self.blobColors = blobColors
|
|
42
|
+
self.innerBlobColor = innerBlobColor
|
|
43
|
+
self.glowColor = glowColor
|
|
44
|
+
self.particleColor = particleColor
|
|
45
|
+
self.progressRingColor = progressRingColor
|
|
46
|
+
self.textColor = textColor
|
|
47
|
+
self.showProgressRing = showProgressRing
|
|
48
|
+
self.showTextCue = showTextCue
|
|
49
|
+
self.showInnerBlob = showInnerBlob
|
|
50
|
+
self.showShadow = showShadow
|
|
51
|
+
self.showParticles = showParticles
|
|
52
|
+
self.showWavyBlobs = showWavyBlobs
|
|
53
|
+
self.showGlowEffects = showGlowEffects
|
|
54
|
+
self.pointCount = pointCount
|
|
55
|
+
self.wobbleIntensity = wobbleIntensity
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
public struct BreathingExerciseView: View {
|
|
4
|
+
private let config: BreathingConfiguration
|
|
5
|
+
|
|
6
|
+
public init(configuration: BreathingConfiguration = BreathingConfiguration()) {
|
|
7
|
+
self.config = configuration
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public var body: some View {
|
|
11
|
+
TimelineView(.animation) { timeline in
|
|
12
|
+
GeometryReader { geometry in
|
|
13
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
14
|
+
let elapsedTime = timeline.date.timeIntervalSinceReferenceDate
|
|
15
|
+
|
|
16
|
+
// Update all animation state
|
|
17
|
+
let animState = updateAllAnimations(at: timeline.date, pointCount: config.pointCount)
|
|
18
|
+
|
|
19
|
+
ZStack {
|
|
20
|
+
// Progress ring (behind blob)
|
|
21
|
+
if config.showProgressRing && animState.isActive {
|
|
22
|
+
ProgressRingView(
|
|
23
|
+
progress: animState.phaseProgress,
|
|
24
|
+
color: config.progressRingColor,
|
|
25
|
+
lineWidth: 4,
|
|
26
|
+
size: size * 0.95
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Base gradient background layer
|
|
31
|
+
LinearGradient(
|
|
32
|
+
colors: config.blobColors,
|
|
33
|
+
startPoint: .bottom,
|
|
34
|
+
endPoint: .top
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Base depth glows - creates depth with rotating glow effects
|
|
38
|
+
baseDepthGlows(size: size, elapsedTime: elapsedTime)
|
|
39
|
+
|
|
40
|
+
// Wavy blobs - adds organic movement with flowing blob shapes
|
|
41
|
+
if config.showWavyBlobs {
|
|
42
|
+
wavyBlob(size: size, elapsedTime: elapsedTime)
|
|
43
|
+
wavyBlobTwo(size: size, elapsedTime: elapsedTime)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Core glow effects - adds bright, energetic core glow animations
|
|
47
|
+
if config.showGlowEffects {
|
|
48
|
+
coreGlowEffects(size: size, elapsedTime: elapsedTime)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Particles - overlays floating particle effects for additional dynamism
|
|
52
|
+
if config.showParticles {
|
|
53
|
+
particleView
|
|
54
|
+
.frame(maxWidth: size, maxHeight: size)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Inner glows overlay for depth
|
|
58
|
+
.overlay {
|
|
59
|
+
realisticInnerGlows
|
|
60
|
+
}
|
|
61
|
+
// Mask with morphing blob shape
|
|
62
|
+
.mask {
|
|
63
|
+
MorphingBlobShape(
|
|
64
|
+
center: CGPoint(x: size / 2, y: size / 2),
|
|
65
|
+
baseRadius: animState.scale * 0.7 * size / 2,
|
|
66
|
+
pointCount: config.pointCount,
|
|
67
|
+
offsets: animState.wobbleOffsets
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
.frame(width: size, height: size)
|
|
71
|
+
.overlay {
|
|
72
|
+
// Text cue
|
|
73
|
+
if config.showTextCue && !animState.label.isEmpty {
|
|
74
|
+
BreathingTextCue(
|
|
75
|
+
text: animState.label,
|
|
76
|
+
color: config.textColor
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
.modifier(
|
|
81
|
+
BreathingShadowModifier(
|
|
82
|
+
colors: config.showShadow ? config.blobColors : [.clear],
|
|
83
|
+
radius: size * 0.06,
|
|
84
|
+
scale: animState.scale
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
.aspectRatio(1, contentMode: .fit)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - Visual Effects (from OrbView)
|
|
93
|
+
|
|
94
|
+
private var particleView: some View {
|
|
95
|
+
ZStack {
|
|
96
|
+
BreathingParticlesView(
|
|
97
|
+
color: config.particleColor,
|
|
98
|
+
particleCount: 15,
|
|
99
|
+
speedRange: 10...20,
|
|
100
|
+
sizeRange: 1...3,
|
|
101
|
+
opacityRange: 0.1...0.4
|
|
102
|
+
)
|
|
103
|
+
.blur(radius: 1)
|
|
104
|
+
|
|
105
|
+
BreathingParticlesView(
|
|
106
|
+
color: config.particleColor,
|
|
107
|
+
particleCount: 10,
|
|
108
|
+
speedRange: 20...35,
|
|
109
|
+
sizeRange: 0.5...2,
|
|
110
|
+
opacityRange: 0.3...0.7
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
.blendMode(.plusLighter)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func wavyBlob(size: CGFloat, elapsedTime: Double) -> some View {
|
|
117
|
+
let blobSpeed: Double = 20
|
|
118
|
+
|
|
119
|
+
return RotatingGlowView(
|
|
120
|
+
color: .white.opacity(0.75),
|
|
121
|
+
rotationSpeed: blobSpeed * 1.5,
|
|
122
|
+
direction: .clockwise,
|
|
123
|
+
elapsedTime: elapsedTime
|
|
124
|
+
)
|
|
125
|
+
.mask {
|
|
126
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 1.75)
|
|
127
|
+
.frame(maxWidth: size * 1.875)
|
|
128
|
+
.offset(x: 0, y: size * 0.31)
|
|
129
|
+
}
|
|
130
|
+
.blur(radius: 1)
|
|
131
|
+
.blendMode(.plusLighter)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func wavyBlobTwo(size: CGFloat, elapsedTime: Double) -> some View {
|
|
135
|
+
let blobSpeed: Double = 20
|
|
136
|
+
|
|
137
|
+
return RotatingGlowView(
|
|
138
|
+
color: .white,
|
|
139
|
+
rotationSpeed: blobSpeed * 0.75,
|
|
140
|
+
direction: .counterClockwise,
|
|
141
|
+
elapsedTime: elapsedTime
|
|
142
|
+
)
|
|
143
|
+
.mask {
|
|
144
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 2.25)
|
|
145
|
+
.frame(maxWidth: size * 1.25)
|
|
146
|
+
.rotationEffect(.degrees(90))
|
|
147
|
+
.offset(x: 0, y: size * -0.31)
|
|
148
|
+
}
|
|
149
|
+
.opacity(0.5)
|
|
150
|
+
.blur(radius: 1)
|
|
151
|
+
.blendMode(.plusLighter)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func coreGlowEffects(size: CGFloat, elapsedTime: Double) -> some View {
|
|
155
|
+
let speed: Double = 18.0
|
|
156
|
+
let glowIntensity: Double = 0.8
|
|
157
|
+
|
|
158
|
+
return ZStack {
|
|
159
|
+
RotatingGlowView(
|
|
160
|
+
color: config.glowColor,
|
|
161
|
+
rotationSpeed: speed * 1.2,
|
|
162
|
+
direction: .clockwise,
|
|
163
|
+
elapsedTime: elapsedTime
|
|
164
|
+
)
|
|
165
|
+
.blur(radius: size * 0.08)
|
|
166
|
+
.opacity(glowIntensity)
|
|
167
|
+
|
|
168
|
+
RotatingGlowView(
|
|
169
|
+
color: config.glowColor,
|
|
170
|
+
rotationSpeed: speed * 0.9,
|
|
171
|
+
direction: .clockwise,
|
|
172
|
+
elapsedTime: elapsedTime
|
|
173
|
+
)
|
|
174
|
+
.blur(radius: size * 0.06)
|
|
175
|
+
.opacity(glowIntensity)
|
|
176
|
+
.blendMode(.plusLighter)
|
|
177
|
+
}
|
|
178
|
+
.padding(size * 0.08)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func baseDepthGlows(size: CGFloat, elapsedTime: Double) -> some View {
|
|
182
|
+
let speed: Double = 18.0
|
|
183
|
+
|
|
184
|
+
return ZStack {
|
|
185
|
+
// Outer glow
|
|
186
|
+
RotatingGlowView(
|
|
187
|
+
color: config.glowColor,
|
|
188
|
+
rotationSpeed: speed * 0.75,
|
|
189
|
+
direction: .counterClockwise,
|
|
190
|
+
elapsedTime: elapsedTime
|
|
191
|
+
)
|
|
192
|
+
.padding(size * 0.03)
|
|
193
|
+
.blur(radius: size * 0.06)
|
|
194
|
+
.rotationEffect(.degrees(180))
|
|
195
|
+
.blendMode(.destinationOver)
|
|
196
|
+
|
|
197
|
+
// Outer ring
|
|
198
|
+
RotatingGlowView(
|
|
199
|
+
color: config.glowColor.opacity(0.5),
|
|
200
|
+
rotationSpeed: speed * 0.25,
|
|
201
|
+
direction: .clockwise,
|
|
202
|
+
elapsedTime: elapsedTime
|
|
203
|
+
)
|
|
204
|
+
.frame(maxWidth: size * 0.94)
|
|
205
|
+
.rotationEffect(.degrees(180))
|
|
206
|
+
.padding(8)
|
|
207
|
+
.blur(radius: size * 0.032)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private var orbOutlineColor: LinearGradient {
|
|
212
|
+
LinearGradient(
|
|
213
|
+
colors: [.white, .clear],
|
|
214
|
+
startPoint: .bottom,
|
|
215
|
+
endPoint: .top
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private var realisticInnerGlows: some View {
|
|
220
|
+
ZStack {
|
|
221
|
+
// Outer stroke with heavy blur
|
|
222
|
+
Circle()
|
|
223
|
+
.stroke(orbOutlineColor, lineWidth: 8)
|
|
224
|
+
.blur(radius: 32)
|
|
225
|
+
.blendMode(.plusLighter)
|
|
226
|
+
|
|
227
|
+
// Inner stroke with light blur
|
|
228
|
+
Circle()
|
|
229
|
+
.stroke(orbOutlineColor, lineWidth: 4)
|
|
230
|
+
.blur(radius: 12)
|
|
231
|
+
.blendMode(.plusLighter)
|
|
232
|
+
|
|
233
|
+
Circle()
|
|
234
|
+
.stroke(orbOutlineColor, lineWidth: 1)
|
|
235
|
+
.blur(radius: 4)
|
|
236
|
+
.blendMode(.plusLighter)
|
|
237
|
+
}
|
|
238
|
+
.padding(1)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private struct AnimState {
|
|
242
|
+
let scale: Double
|
|
243
|
+
let wobbleOffsets: [Double]
|
|
244
|
+
let phaseProgress: Double
|
|
245
|
+
let label: String
|
|
246
|
+
let isActive: Bool
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private func updateAllAnimations(at date: Date, pointCount: Int) -> AnimState {
|
|
250
|
+
let state = BreathingSharedState.shared
|
|
251
|
+
|
|
252
|
+
// Always update wobble animation (organic movement even when idle)
|
|
253
|
+
updateWobble(at: date, pointCount: pointCount)
|
|
254
|
+
|
|
255
|
+
// Handle breathing state
|
|
256
|
+
switch state.state {
|
|
257
|
+
case .stopped, .complete:
|
|
258
|
+
return AnimState(
|
|
259
|
+
scale: 1.0,
|
|
260
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
261
|
+
phaseProgress: 0,
|
|
262
|
+
label: "",
|
|
263
|
+
isActive: false
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
case .paused:
|
|
267
|
+
return AnimState(
|
|
268
|
+
scale: state.currentScale,
|
|
269
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
270
|
+
phaseProgress: state.phaseProgress,
|
|
271
|
+
label: state.currentLabel,
|
|
272
|
+
isActive: true
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
case .running:
|
|
276
|
+
// Update phase timing
|
|
277
|
+
updatePhaseState(at: date)
|
|
278
|
+
|
|
279
|
+
// Calculate scale and progress based on phase
|
|
280
|
+
let currentPhaseConfig = state.phases.isEmpty ? nil : state.phases[state.currentPhaseIndex]
|
|
281
|
+
let scale: Double
|
|
282
|
+
let ringProgress: Double
|
|
283
|
+
|
|
284
|
+
if let phase = currentPhaseConfig?.phase {
|
|
285
|
+
switch phase {
|
|
286
|
+
case .inhale:
|
|
287
|
+
// Ease-out cubic for smooth deceleration toward target
|
|
288
|
+
let easedProgress = easeOutCubic(state.phaseProgress)
|
|
289
|
+
scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
|
|
290
|
+
ringProgress = easedProgress // 0 -> 1
|
|
291
|
+
case .exhale:
|
|
292
|
+
let easedProgress = easeOutCubic(state.phaseProgress)
|
|
293
|
+
scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
|
|
294
|
+
ringProgress = 1.0 - easedProgress // 1 -> 0
|
|
295
|
+
case .holdIn:
|
|
296
|
+
// Hold at expanded scale, ring stays full
|
|
297
|
+
scale = state.targetScale
|
|
298
|
+
ringProgress = 1.0
|
|
299
|
+
case .holdOut:
|
|
300
|
+
// Hold at contracted scale, ring stays empty
|
|
301
|
+
scale = state.targetScale
|
|
302
|
+
ringProgress = 0.0
|
|
303
|
+
case .idle:
|
|
304
|
+
scale = state.targetScale
|
|
305
|
+
ringProgress = 0.0
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
scale = 1.0
|
|
309
|
+
ringProgress = 0.0
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
state.currentScale = scale
|
|
313
|
+
|
|
314
|
+
return AnimState(
|
|
315
|
+
scale: scale,
|
|
316
|
+
wobbleOffsets: state.wobbleOffsets,
|
|
317
|
+
phaseProgress: ringProgress,
|
|
318
|
+
label: state.currentLabel,
|
|
319
|
+
isActive: true
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func updatePhaseState(at date: Date) {
|
|
325
|
+
let state = BreathingSharedState.shared
|
|
326
|
+
guard !state.phases.isEmpty else { return }
|
|
327
|
+
|
|
328
|
+
let currentPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
329
|
+
let elapsed = date.timeIntervalSince(state.phaseStartTime)
|
|
330
|
+
let duration = currentPhaseConfig.duration
|
|
331
|
+
|
|
332
|
+
// Calculate progress through current phase
|
|
333
|
+
state.phaseProgress = min(1.0, elapsed / duration)
|
|
334
|
+
|
|
335
|
+
// Check if phase is complete
|
|
336
|
+
if elapsed >= duration {
|
|
337
|
+
// Move to next phase
|
|
338
|
+
state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.count
|
|
339
|
+
|
|
340
|
+
// Check if we completed a cycle
|
|
341
|
+
if state.currentPhaseIndex == 0 {
|
|
342
|
+
state.currentCycle += 1
|
|
343
|
+
|
|
344
|
+
// Check if exercise is complete
|
|
345
|
+
if let totalCycles = state.totalCycles, state.currentCycle >= totalCycles {
|
|
346
|
+
state.state = .complete
|
|
347
|
+
state.totalDuration = date.timeIntervalSince(state.exerciseStartTime)
|
|
348
|
+
state.onExerciseComplete?(state.currentCycle, state.totalDuration)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Start new phase
|
|
354
|
+
state.phaseStartTime = date
|
|
355
|
+
let newPhaseConfig = state.phases[state.currentPhaseIndex]
|
|
356
|
+
state.currentPhase = newPhaseConfig.phase
|
|
357
|
+
state.currentLabel = newPhaseConfig.label
|
|
358
|
+
state.startScale = state.currentScale // Remember where we're starting from
|
|
359
|
+
state.targetScale = newPhaseConfig.targetScale
|
|
360
|
+
state.phaseProgress = 0
|
|
361
|
+
|
|
362
|
+
// Update wobble intensity based on phase
|
|
363
|
+
switch newPhaseConfig.phase {
|
|
364
|
+
case .inhale, .exhale:
|
|
365
|
+
state.wobbleIntensity = 1.0
|
|
366
|
+
case .holdIn, .holdOut:
|
|
367
|
+
state.wobbleIntensity = 0.3
|
|
368
|
+
case .idle:
|
|
369
|
+
state.wobbleIntensity = 0.5
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Fire phase change callback
|
|
373
|
+
state.onPhaseChange?(
|
|
374
|
+
newPhaseConfig.phase,
|
|
375
|
+
newPhaseConfig.label,
|
|
376
|
+
state.currentPhaseIndex,
|
|
377
|
+
state.currentCycle
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private func updateWobble(at date: Date, pointCount: Int) {
|
|
383
|
+
let state = BreathingSharedState.shared
|
|
384
|
+
|
|
385
|
+
// Initialize arrays if needed
|
|
386
|
+
if state.wobbleOffsets.count != pointCount {
|
|
387
|
+
state.wobbleOffsets = (0..<pointCount).map { _ in 0.0 }
|
|
388
|
+
state.wobbleTargets = (0..<pointCount).map { _ in 0.0 }
|
|
389
|
+
state.lastWobbleUpdate = date
|
|
390
|
+
state.lastWobbleTargetUpdate = date
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let now = date
|
|
394
|
+
let dt = now.timeIntervalSince(state.lastWobbleUpdate)
|
|
395
|
+
state.lastWobbleUpdate = now
|
|
396
|
+
|
|
397
|
+
// Generate new random targets periodically (slower, more organic)
|
|
398
|
+
let timeSinceTargetUpdate = now.timeIntervalSince(state.lastWobbleTargetUpdate)
|
|
399
|
+
if timeSinceTargetUpdate > 1.8 {
|
|
400
|
+
state.lastWobbleTargetUpdate = now
|
|
401
|
+
|
|
402
|
+
let intensity = state.wobbleIntensity * config.wobbleIntensity
|
|
403
|
+
let maxOffset = 0.18 * intensity
|
|
404
|
+
|
|
405
|
+
state.wobbleTargets = (0..<pointCount).map { i in
|
|
406
|
+
let phase = Double(i) / Double(pointCount) * 2.0 * .pi
|
|
407
|
+
let wave1 = sin(phase * 2 + Double.random(in: 0...1)) * 0.6
|
|
408
|
+
let wave2 = cos(phase * 3 + Double.random(in: 0...1)) * 0.4
|
|
409
|
+
let baseOffset = Double.random(in: -maxOffset...maxOffset)
|
|
410
|
+
return baseOffset * (1.0 + wave1 + wave2) * 0.5
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Smoothly interpolate current offsets toward targets
|
|
415
|
+
let interpolationSpeed = 1.8
|
|
416
|
+
let factor = min(1.0, dt * interpolationSpeed)
|
|
417
|
+
|
|
418
|
+
for i in 0..<pointCount {
|
|
419
|
+
let current = state.wobbleOffsets[i]
|
|
420
|
+
let target = state.wobbleTargets[i]
|
|
421
|
+
state.wobbleOffsets[i] = current + (target - current) * factor
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
struct BreathingShadowModifier: ViewModifier {
|
|
427
|
+
let colors: [Color]
|
|
428
|
+
let radius: CGFloat
|
|
429
|
+
let scale: Double
|
|
430
|
+
|
|
431
|
+
func body(content: Content) -> some View {
|
|
432
|
+
content
|
|
433
|
+
.shadow(color: colors.first?.opacity(0.4) ?? .clear, radius: radius * scale, x: 0, y: radius * 0.3 * scale)
|
|
434
|
+
.shadow(color: colors.first?.opacity(0.2) ?? .clear, radius: radius * 2 * scale, x: 0, y: radius * 0.5 * scale)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Easing functions
|
|
439
|
+
private func easeOutCubic(_ t: Double) -> Double {
|
|
440
|
+
let t1 = t - 1
|
|
441
|
+
return t1 * t1 * t1 + 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private func easeInOutCubic(_ t: Double) -> Double {
|
|
445
|
+
if t < 0.5 {
|
|
446
|
+
return 4 * t * t * t
|
|
447
|
+
} else {
|
|
448
|
+
let t1 = -2 * t + 2
|
|
449
|
+
return 1 - (t1 * t1 * t1) / 2
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -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
|
+
}
|