chore: remove legacy apps/mobile, apps/web, publisher and dead root config
This commit is contained in:
41
.easignore
41
.easignore
@@ -1,41 +0,0 @@
|
|||||||
# Applied when EAS bundles the workspace root for an iOS build of apps/mobile
|
|
||||||
# (see apps/flux/core/src/services/interactive-terminal/create-shell.ts).
|
|
||||||
# Keep yarn-workspace context (root package.json, yarn.lock, .yarnrc.yml,
|
|
||||||
# .yarn/patches/) and apps/mobile/ — exclude everything else so EAS uploads
|
|
||||||
# stay small.
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
**/node_modules/
|
|
||||||
.yarn/cache/
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.yarn/unplugged/
|
|
||||||
|
|
||||||
apps/web/
|
|
||||||
|
|
||||||
config/
|
|
||||||
shared/
|
|
||||||
# Re-include the metro polyfills directory: `shared/` above is unanchored and
|
|
||||||
# would otherwise match `apps/mobile/polyfills/shared/`, which holds the
|
|
||||||
# expo-image and empty-component shims that metro.config.js redirects to via
|
|
||||||
# SHARED_ALIASES. Without these files in the EAS upload, EAGER_BUNDLE fails
|
|
||||||
# with `Unable to resolve module expo-image` on every file that imports it.
|
|
||||||
!apps/mobile/polyfills/shared/
|
|
||||||
examples/
|
|
||||||
playwright-report/
|
|
||||||
test-results/
|
|
||||||
caches/
|
|
||||||
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.test.yml
|
|
||||||
README.md
|
|
||||||
.dockerignore
|
|
||||||
.eslintignore
|
|
||||||
.gitignore
|
|
||||||
.oxfmtrc.json
|
|
||||||
.oxlintrc.json
|
|
||||||
|
|
||||||
*.log
|
|
||||||
*.tgz
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
.DS_Store
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
*
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
diff --git a/build/src/start/server/middleware/ExpoGoManifestHandlerMiddleware.js b/build/src/start/server/middleware/ExpoGoManifestHandlerMiddleware.js
|
|
||||||
index b5cba1b..2cbb2a5 100644
|
|
||||||
--- a/build/src/start/server/middleware/ExpoGoManifestHandlerMiddleware.js
|
|
||||||
+++ b/build/src/start/server/middleware/ExpoGoManifestHandlerMiddleware.js
|
|
||||||
@@ -143,7 +143,9 @@ class ExpoGoManifestHandlerMiddleware extends _ManifestMiddleware.ManifestMiddle
|
|
||||||
codeSigningInfo
|
|
||||||
});
|
|
||||||
const expoUpdatesManifest = {
|
|
||||||
- id: _crypto().default.randomUUID(),
|
|
||||||
+ id: _crypto().default.randomUUID({
|
|
||||||
+ disableEntropyCache: true
|
|
||||||
+ }),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
runtimeVersion,
|
|
||||||
launchAsset: {
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
diff --git a/src/error-overlay/ErrorOverlay.tsx b/src/error-overlay/ErrorOverlay.tsx
|
|
||||||
index 983dc52..bbe737c 100644
|
|
||||||
--- a/src/error-overlay/ErrorOverlay.tsx
|
|
||||||
+++ b/src/error-overlay/ErrorOverlay.tsx
|
|
||||||
@@ -30,6 +30,7 @@ const HEADER_TITLE_MAP = {
|
|
||||||
export function LogBoxInspectorContainer() {
|
|
||||||
const { selectedLogIndex, logs } = useLogs();
|
|
||||||
const log = logs[selectedLogIndex];
|
|
||||||
+ return null;
|
|
||||||
if (log == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
diff --git a/src/error-overlay/toast/ErrorToast.tsx b/src/error-overlay/toast/ErrorToast.tsx
|
|
||||||
index 87a0c8b..c044c8f 100644
|
|
||||||
--- a/src/error-overlay/toast/ErrorToast.tsx
|
|
||||||
+++ b/src/error-overlay/toast/ErrorToast.tsx
|
|
||||||
@@ -34,6 +34,7 @@ export function ErrorToast(props: Props) {
|
|
||||||
|
|
||||||
useSymbolicatedLog(log);
|
|
||||||
|
|
||||||
+ return null;
|
|
||||||
return (
|
|
||||||
<View style={toastStyles.container}>
|
|
||||||
<Pressable style={{ flex: 1 }} onPress={props.onPressOpen}>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
diff --git a/src/internal/nativeInterface.ts b/src/internal/nativeInterface.ts
|
|
||||||
index 8b514f4..9135364 100644
|
|
||||||
--- a/src/internal/nativeInterface.ts
|
|
||||||
+++ b/src/internal/nativeInterface.ts
|
|
||||||
@@ -7,28 +7,15 @@
|
|
||||||
* @format
|
|
||||||
*/
|
|
||||||
|
|
||||||
-import {NativeEventEmitter} from 'react-native';
|
|
||||||
+import { NativeEventEmitter } from 'react-native';
|
|
||||||
import RNCNetInfo from './nativeModule';
|
|
||||||
|
|
||||||
-// Produce an error if we don't have the native module
|
|
||||||
-if (!RNCNetInfo) {
|
|
||||||
- throw new Error(`@react-native-community/netinfo: NativeModule.RNCNetInfo is null. To fix this issue try these steps:
|
|
||||||
-
|
|
||||||
-• Run \`react-native link @react-native-community/netinfo\` in the project root.
|
|
||||||
-• Rebuild and re-run the app.
|
|
||||||
-• If you are using CocoaPods on iOS, run \`pod install\` in the \`ios\` directory and then rebuild and re-run the app. You may also need to re-open Xcode to get the new pods.
|
|
||||||
-• Check that the library was linked correctly when you used the link command by running through the manual installation instructions in the README.
|
|
||||||
-* If you are getting this error while unit testing you need to mock the native module. Follow the guide in the README.
|
|
||||||
-
|
|
||||||
-If none of these fix the issue, please open an issue on the Github repository: https://github.com/react-native-community/react-native-netinfo`);
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
/**
|
|
||||||
* We export the native interface in this way to give easy shared access to it between the
|
|
||||||
* JavaScript code and the tests
|
|
||||||
*/
|
|
||||||
let nativeEventEmitter: NativeEventEmitter | null = null;
|
|
||||||
-const nativeInterface = Object.assign(RNCNetInfo, {
|
|
||||||
+const nativeInterface = RNCNetInfo ? Object.assign(RNCNetInfo, {
|
|
||||||
get eventEmitter(): NativeEventEmitter {
|
|
||||||
if (!nativeEventEmitter) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
@@ -39,5 +26,5 @@ const nativeInterface = Object.assign(RNCNetInfo, {
|
|
||||||
/// @ts-ignore
|
|
||||||
return nativeEventEmitter;
|
|
||||||
},
|
|
||||||
-});
|
|
||||||
+}) : {};
|
|
||||||
export default nativeInterface;
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
diff --git a/assets/native-tabs.module.css b/assets/native-tabs.module.css
|
|
||||||
index f29cec5..0d71dad 100644
|
|
||||||
--- a/assets/native-tabs.module.css
|
|
||||||
+++ b/assets/native-tabs.module.css
|
|
||||||
@@ -22,22 +22,27 @@
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigationMenuRoot {
|
|
||||||
- top: 24px;
|
|
||||||
+ bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
position: fixed;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
- background-color: var(--expo-router-tabs-background-color, #272727);
|
|
||||||
- height: 40px;
|
|
||||||
- border-radius: 25px;
|
|
||||||
+ background-color: var(--expo-router-tabs-background-color, rgba(30, 30, 30, 0.88));
|
|
||||||
+ backdrop-filter: blur(20px);
|
|
||||||
+ -webkit-backdrop-filter: blur(20px);
|
|
||||||
+ height: 56px;
|
|
||||||
+ border-radius: 28px;
|
|
||||||
align-items: center;
|
|
||||||
- justify-content: flex-start;
|
|
||||||
- padding: 5px;
|
|
||||||
+ justify-content: center;
|
|
||||||
+ padding: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
- max-width: 90vw;
|
|
||||||
- overflow-x: auto;
|
|
||||||
+ max-width: 95vw;
|
|
||||||
+ overflow: hidden;
|
|
||||||
+ border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
|
||||||
+ gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigationMenuTrigger {
|
|
||||||
@@ -48,36 +53,64 @@
|
|
||||||
height: 100%;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
- margin: 0;
|
|
||||||
- height: 100%;
|
|
||||||
- border-radius: 20px;
|
|
||||||
- padding: 0 20px;
|
|
||||||
+ border-radius: 24px;
|
|
||||||
+ padding: 6px 16px;
|
|
||||||
+ transition: background-color 0.2s ease, backdrop-filter 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
- outline-color: var(--expo-router-tabs-tab-outline-color, #444444);
|
|
||||||
+ outline-color: var(--expo-router-tabs-tab-outline-color, rgba(255, 255, 255, 0.2));
|
|
||||||
position: relative;
|
|
||||||
+ display: flex;
|
|
||||||
+ flex-direction: column;
|
|
||||||
+ align-items: center;
|
|
||||||
+ justify-content: center;
|
|
||||||
+ gap: 2px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.tabIcon {
|
|
||||||
+ display: flex;
|
|
||||||
+ align-items: center;
|
|
||||||
+ justify-content: center;
|
|
||||||
+ color: var(--expo-router-tabs-icon-color, rgba(255, 255, 255, 0.6));
|
|
||||||
+ font-size: 18px;
|
|
||||||
+ width: 20px;
|
|
||||||
+ height: 20px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.tabIcon > * {
|
|
||||||
+ color: inherit;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.navigationMenuTrigger[data-state="active"] .tabIcon {
|
|
||||||
+ color: var(--expo-router-tabs-active-icon-color, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigationMenuTrigger[data-state="active"] {
|
|
||||||
- background-color: var(--expo-router-tabs-active-background-color, #444444);
|
|
||||||
+ background-color: var(--expo-router-tabs-active-background-color, rgba(255, 255, 255, 0.15));
|
|
||||||
+ backdrop-filter: blur(10px);
|
|
||||||
+ -webkit-backdrop-filter: blur(10px);
|
|
||||||
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabText {
|
|
||||||
font-weight: var(--expo-router-tabs-font-weight, 500);
|
|
||||||
- font-size: var(--expo-router-tabs-font-size, 15px);
|
|
||||||
+ font-size: var(--expo-router-tabs-font-size, 11px);
|
|
||||||
font-family: var(--expo-router-tabs-font-family);
|
|
||||||
font-style: var(--expo-router-tabs-font-style, normal);
|
|
||||||
opacity: var(--expo-router-tabs-text-opacity, 1);
|
|
||||||
- color: var(--expo-router-tabs-text-color, #8b8b8b);
|
|
||||||
+ color: var(--expo-router-tabs-text-color, rgba(255, 255, 255, 0.6));
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigationMenuTrigger[data-state="active"] .tabText {
|
|
||||||
color: var(--expo-router-tabs-active-text-color, #ffffff);
|
|
||||||
- font-size: var(--expo-router-tabs-active-font-size, var(--expo-router-tabs-font-size, 15px));
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.navigationMenuTrigger:not([data-state="active"]):hover {
|
|
||||||
+ background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigationMenuTrigger:not([data-state="active"]) .tabText:hover {
|
|
||||||
- opacity: var(--expo-router-tabs-text-hover-opacity, 0.6);
|
|
||||||
+ opacity: var(--expo-router-tabs-text-hover-opacity, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBadge {
|
|
||||||
@@ -107,3 +140,101 @@
|
|
||||||
min-height: var(--expo-router-tabs-local-badge-size);
|
|
||||||
border-radius: calc(var(--expo-router-tabs-local-badge-size) / 2);
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+/* More screen styles - Apple Settings inspired */
|
|
||||||
+.moreScreen {
|
|
||||||
+ flex: 1;
|
|
||||||
+ display: flex;
|
|
||||||
+ flex-direction: column;
|
|
||||||
+ background-color: #000000;
|
|
||||||
+ max-height: 100%;
|
|
||||||
+ max-width: 100%;
|
|
||||||
+ overflow-y: auto;
|
|
||||||
+ padding-bottom: 120px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenHeader {
|
|
||||||
+ padding: 60px 20px 8px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenTitle {
|
|
||||||
+ font-family: var(--expo-router-tabs-font-family);
|
|
||||||
+ font-size: 34px;
|
|
||||||
+ font-weight: 700;
|
|
||||||
+ color: #ffffff;
|
|
||||||
+ margin: 0;
|
|
||||||
+ letter-spacing: 0.37px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenContent {
|
|
||||||
+ display: flex;
|
|
||||||
+ flex-direction: column;
|
|
||||||
+ padding: 20px 20px 0;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenGroup {
|
|
||||||
+ background-color: #1c1c1e;
|
|
||||||
+ border-radius: 10px;
|
|
||||||
+ overflow: hidden;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItem {
|
|
||||||
+ display: flex;
|
|
||||||
+ align-items: center;
|
|
||||||
+ gap: 12px;
|
|
||||||
+ padding: 8px 16px 8px 12px;
|
|
||||||
+ min-height: 44px;
|
|
||||||
+ border: none;
|
|
||||||
+ background-color: transparent;
|
|
||||||
+ cursor: pointer;
|
|
||||||
+ font-family: var(--expo-router-tabs-font-family);
|
|
||||||
+ text-align: left;
|
|
||||||
+ transition: background-color 0.1s ease;
|
|
||||||
+ position: relative;
|
|
||||||
+ width: 100%;
|
|
||||||
+ box-sizing: border-box;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItem:hover {
|
|
||||||
+ background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItem:active {
|
|
||||||
+ background-color: rgba(255, 255, 255, 0.12);
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+/* Separator line between items */
|
|
||||||
+.moreScreenItem:not(:last-child)::after {
|
|
||||||
+ content: '';
|
|
||||||
+ position: absolute;
|
|
||||||
+ bottom: 0;
|
|
||||||
+ left: 54px;
|
|
||||||
+ right: 0;
|
|
||||||
+ height: 0.5px;
|
|
||||||
+ background-color: rgba(84, 84, 88, 0.65);
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItemIcon {
|
|
||||||
+ display: flex;
|
|
||||||
+ align-items: center;
|
|
||||||
+ justify-content: center;
|
|
||||||
+ width: 30px;
|
|
||||||
+ height: 30px;
|
|
||||||
+ border-radius: 6px;
|
|
||||||
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0) 100%), #636366;
|
|
||||||
+ color: #ffffff;
|
|
||||||
+ flex-shrink: 0;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItemLabel {
|
|
||||||
+ flex: 1;
|
|
||||||
+ font-size: 17px;
|
|
||||||
+ font-weight: 400;
|
|
||||||
+ color: #ffffff;
|
|
||||||
+ letter-spacing: -0.41px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.moreScreenItemChevron {
|
|
||||||
+ color: rgba(235, 235, 245, 0.3);
|
|
||||||
+ flex-shrink: 0;
|
|
||||||
+}
|
|
||||||
diff --git a/build/getLinkingConfig.js b/build/getLinkingConfig.js
|
|
||||||
index 2a0ee73..302afc0 100644
|
|
||||||
--- a/build/getLinkingConfig.js
|
|
||||||
+++ b/build/getLinkingConfig.js
|
|
||||||
@@ -8,6 +8,7 @@ const constants_1 = require("./constants");
|
|
||||||
const getReactNavigationConfig_1 = require("./getReactNavigationConfig");
|
|
||||||
const getRoutesRedirects_1 = require("./getRoutesRedirects");
|
|
||||||
const linking_1 = require("./link/linking");
|
|
||||||
+const isAnythingApp = expo_modules_core_1.Platform.OS === 'ios' && !globalThis.expo?.modules?.ExpoGo;
|
|
||||||
function getNavigationConfig(routes, metaOnly, { sitemap, notFound }) {
|
|
||||||
const config = (0, getReactNavigationConfig_1.getReactNavigationConfig)(routes, metaOnly);
|
|
||||||
const sitemapRoute = (() => {
|
|
||||||
@@ -61,7 +62,10 @@ function getLinkingConfig(routes, context, getRouteInfo, { metaOnly = true, serv
|
|
||||||
// Expo Router calls `getInitialURL` twice, which may confuse the user if they provide a custom `getInitialURL`.
|
|
||||||
// Therefor we memoize the result.
|
|
||||||
if (!hasCachedInitialUrl) {
|
|
||||||
- if (expo_modules_core_1.Platform.OS === 'web') {
|
|
||||||
+ if (isAnythingApp) {
|
|
||||||
+ initialUrl = '/';
|
|
||||||
+ }
|
|
||||||
+ else if (expo_modules_core_1.Platform.OS === 'web') {
|
|
||||||
initialUrl = serverUrl ?? (0, linking_1.getInitialURL)();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
diff --git a/build/link/linking.js b/build/link/linking.js
|
|
||||||
index b9535b5..ec99c96 100644
|
|
||||||
--- a/build/link/linking.js
|
|
||||||
+++ b/build/link/linking.js
|
|
||||||
@@ -47,6 +47,7 @@ Object.defineProperty(exports, "getStateFromPath", { enumerable: true, get: func
|
|
||||||
const useLinking_1 = require("../fork/useLinking");
|
|
||||||
const getRoutesRedirects_1 = require("../getRoutesRedirects");
|
|
||||||
const isExpoGo = typeof expo !== 'undefined' && globalThis.expo?.modules?.ExpoGo;
|
|
||||||
+const isAnythingApp = react_native_1.Platform.OS === 'ios' && !globalThis.expo?.modules?.ExpoGo;
|
|
||||||
// A custom getInitialURL is used on native to ensure the app always starts at
|
|
||||||
// the root path if it's launched from something other than a deep link.
|
|
||||||
// This helps keep the native functionality working like the web functionality.
|
|
||||||
@@ -124,7 +125,12 @@ function subscribe(nativeLinking, redirects) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
- const subscription = Linking.addEventListener('url', callback);
|
|
||||||
+ let subscription;
|
|
||||||
+
|
|
||||||
+ if (!isAnythingApp) {
|
|
||||||
+ subscription = Linking.addEventListener('url', callback);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
return () => {
|
|
||||||
// https://github.com/facebook/react-native/commit/6d1aca806cee86ad76de771ed3a1cc62982ebcd7
|
|
||||||
subscription?.remove?.();
|
|
||||||
diff --git a/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js b/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js
|
|
||||||
index cd3d597..8bc0b00 100644
|
|
||||||
--- a/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js
|
|
||||||
+++ b/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js
|
|
||||||
@@ -129,6 +129,16 @@ function appendLabelOptions(options, props) {
|
|
||||||
else {
|
|
||||||
options.title = props.children;
|
|
||||||
options.selectedLabelStyle = props.selectedStyle;
|
|
||||||
+ // Extract label color for web
|
|
||||||
+ if (props.style?.color) {
|
|
||||||
+ options.webLabelColor = props.style.color;
|
|
||||||
+ }
|
|
||||||
+ if (props.color) {
|
|
||||||
+ options.webLabelColor = props.color;
|
|
||||||
+ }
|
|
||||||
+ if (props.selectedStyle?.color) {
|
|
||||||
+ options.webLabelSelectedColor = props.selectedStyle.color;
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function appendIconOptions(options, props) {
|
|
||||||
@@ -136,6 +146,21 @@ function appendIconOptions(options, props) {
|
|
||||||
const icon = convertIconSrcToIconOption(props);
|
|
||||||
options.icon = icon?.icon;
|
|
||||||
options.selectedIcon = icon?.selectedIcon;
|
|
||||||
+ // Preserve icon info for web rendering
|
|
||||||
+ const srcValue = typeof props.src === 'object' && 'selected' in props.src ? props.src.default : props.src;
|
|
||||||
+ if ((0, react_1.isValidElement)(srcValue) && srcValue.type === elements_1.VectorIcon) {
|
|
||||||
+ options.webIconFamily = srcValue.props.family;
|
|
||||||
+ options.webIconName = srcValue.props.name;
|
|
||||||
+ // Extract colors from VectorIcon props for web
|
|
||||||
+ if (srcValue.props.color) {
|
|
||||||
+ options.webIconColor = srcValue.props.color;
|
|
||||||
+ }
|
|
||||||
+ if (srcValue.props.selectedColor) {
|
|
||||||
+ options.webIconSelectedColor = srcValue.props.selectedColor;
|
|
||||||
+ }
|
|
||||||
+ } else {
|
|
||||||
+ options.webIcon = srcValue;
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
else if ('sf' in props && process.env.EXPO_OS === 'ios') {
|
|
||||||
if (typeof props.sf === 'string') {
|
|
||||||
@@ -169,6 +194,13 @@ function appendIconOptions(options, props) {
|
|
||||||
options.selectedIcon = undefined;
|
|
||||||
}
|
|
||||||
options.selectedIconColor = props.selectedColor;
|
|
||||||
+ // Extract icon color for web
|
|
||||||
+ if (props.color) {
|
|
||||||
+ options.webIconColor = props.color;
|
|
||||||
+ }
|
|
||||||
+ if (props.selectedColor) {
|
|
||||||
+ options.webIconSelectedColor = props.selectedColor;
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
function convertIconSrcToIconOption(icon) {
|
|
||||||
if (icon && icon.src) {
|
|
||||||
diff --git a/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js b/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js
|
|
||||||
index d3d738b..a27f83d 100644
|
|
||||||
--- a/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js
|
|
||||||
+++ b/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js
|
|
||||||
@@ -41,16 +41,45 @@ const react_tabs_1 = require("@radix-ui/react-tabs");
|
|
||||||
const react_1 = __importStar(require("react"));
|
|
||||||
const utils_1 = require("./utils");
|
|
||||||
const native_tabs_module_css_1 = __importDefault(require("../../../assets/native-tabs.module.css"));
|
|
||||||
+
|
|
||||||
+const MAX_VISIBLE_TABS = 5;
|
|
||||||
+
|
|
||||||
function NativeTabsView(props) {
|
|
||||||
const { builder, focusedIndex } = props;
|
|
||||||
const { state, descriptors, navigation } = builder;
|
|
||||||
const { routes } = state;
|
|
||||||
+ const [showMoreScreen, setShowMoreScreen] = (0, react_1.useState)(false);
|
|
||||||
const defaultTabName = (0, react_1.useMemo)(() => state.routes[focusedIndex]?.name ?? state.routes[0].name, []);
|
|
||||||
const value = state.routes[focusedIndex]?.name ?? state.routes[0].name;
|
|
||||||
const currentTabKey = state.routes[focusedIndex]?.key ?? state.routes[0].key;
|
|
||||||
- const items = routes
|
|
||||||
- .filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options))
|
|
||||||
- .map((route) => (<TabItem key={route.key} route={route} title={descriptors[route.key].options.title ?? route.name} badgeValue={descriptors[route.key].options.badgeValue}/>));
|
|
||||||
+
|
|
||||||
+ const visibleRoutes = routes.filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options));
|
|
||||||
+ const hasOverflow = visibleRoutes.length > MAX_VISIBLE_TABS;
|
|
||||||
+ const primaryRoutes = hasOverflow ? visibleRoutes.slice(0, MAX_VISIBLE_TABS - 1) : visibleRoutes;
|
|
||||||
+ const overflowRoutes = hasOverflow ? visibleRoutes.slice(MAX_VISIBLE_TABS - 1) : [];
|
|
||||||
+
|
|
||||||
+ // Check if an overflow tab is currently active
|
|
||||||
+ const isOverflowTabActive = overflowRoutes.some(route => route.name === value);
|
|
||||||
+
|
|
||||||
+ const items = primaryRoutes.map((route) => (
|
|
||||||
+ <TabItem
|
|
||||||
+ key={route.key}
|
|
||||||
+ route={route}
|
|
||||||
+ title={descriptors[route.key].options.title ?? route.name}
|
|
||||||
+ badgeValue={descriptors[route.key].options.badgeValue}
|
|
||||||
+ webIcon={descriptors[route.key].options.webIcon}
|
|
||||||
+ webIconFamily={descriptors[route.key].options.webIconFamily}
|
|
||||||
+ webIconName={descriptors[route.key].options.webIconName}
|
|
||||||
+ webIconColor={descriptors[route.key].options.webIconColor}
|
|
||||||
+ webIconSelectedColor={descriptors[route.key].options.webIconSelectedColor}
|
|
||||||
+ webLabelColor={descriptors[route.key].options.webLabelColor}
|
|
||||||
+ webLabelSelectedColor={descriptors[route.key].options.webLabelSelectedColor}
|
|
||||||
+ onClick={() => setShowMoreScreen(false)}
|
|
||||||
+ forceInactive={showMoreScreen}
|
|
||||||
+ isActive={route.name === value && !showMoreScreen}
|
|
||||||
+ />
|
|
||||||
+ ));
|
|
||||||
+
|
|
||||||
const children = routes
|
|
||||||
.filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options))
|
|
||||||
.map((route) => {
|
|
||||||
@@ -58,26 +87,116 @@ function NativeTabsView(props) {
|
|
||||||
{descriptors[route.key].render()}
|
|
||||||
</react_tabs_1.TabsContent>);
|
|
||||||
});
|
|
||||||
- return (<react_tabs_1.Tabs className={native_tabs_module_css_1.default.nativeTabsContainer} defaultValue={defaultTabName} value={value} onValueChange={(value) => {
|
|
||||||
- navigation.dispatch({
|
|
||||||
- type: 'JUMP_TO',
|
|
||||||
- target: state.key,
|
|
||||||
- payload: {
|
|
||||||
- name: value,
|
|
||||||
- },
|
|
||||||
- });
|
|
||||||
+
|
|
||||||
+ const handleNavigate = (routeName) => {
|
|
||||||
+ navigation.dispatch({
|
|
||||||
+ type: 'JUMP_TO',
|
|
||||||
+ target: state.key,
|
|
||||||
+ payload: {
|
|
||||||
+ name: routeName,
|
|
||||||
+ },
|
|
||||||
+ });
|
|
||||||
+ setShowMoreScreen(false);
|
|
||||||
+ };
|
|
||||||
+
|
|
||||||
+ return (<react_tabs_1.Tabs className={native_tabs_module_css_1.default.nativeTabsContainer} defaultValue={defaultTabName} value={value} onValueChange={(newValue) => {
|
|
||||||
+ handleNavigate(newValue);
|
|
||||||
}} style={convertNativeTabsPropsToStyleVars(props, descriptors[currentTabKey]?.options)}>
|
|
||||||
+
|
|
||||||
+ {/* More Screen - shown when More tab is active */}
|
|
||||||
+ {showMoreScreen && (
|
|
||||||
+ <div className={native_tabs_module_css_1.default.moreScreen}>
|
|
||||||
+ <div className={native_tabs_module_css_1.default.moreScreenHeader}>
|
|
||||||
+ <h1 className={native_tabs_module_css_1.default.moreScreenTitle}>More</h1>
|
|
||||||
+ </div>
|
|
||||||
+ <div className={native_tabs_module_css_1.default.moreScreenContent}>
|
|
||||||
+ <div className={native_tabs_module_css_1.default.moreScreenGroup}>
|
|
||||||
+ {overflowRoutes.map((route) => (
|
|
||||||
+ <button
|
|
||||||
+ key={route.key}
|
|
||||||
+ type="button"
|
|
||||||
+ className={native_tabs_module_css_1.default.moreScreenItem}
|
|
||||||
+ onClick={() => handleNavigate(route.name)}
|
|
||||||
+ >
|
|
||||||
+ <div className={native_tabs_module_css_1.default.moreScreenItemIcon}>
|
|
||||||
+ <OverflowTabIcon
|
|
||||||
+ webIcon={descriptors[route.key].options.webIcon}
|
|
||||||
+ webIconFamily={descriptors[route.key].options.webIconFamily}
|
|
||||||
+ webIconName={descriptors[route.key].options.webIconName}
|
|
||||||
+ />
|
|
||||||
+ </div>
|
|
||||||
+ <span className={native_tabs_module_css_1.default.moreScreenItemLabel}>
|
|
||||||
+ {descriptors[route.key].options.title ?? route.name}
|
|
||||||
+ </span>
|
|
||||||
+ <svg className={native_tabs_module_css_1.default.moreScreenItemChevron} width="7" height="12" viewBox="0 0 7 12" fill="none">
|
|
||||||
+ <path d="M1 1L6 6L1 11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
+ </svg>
|
|
||||||
+ </button>
|
|
||||||
+ ))}
|
|
||||||
+ </div>
|
|
||||||
+ </div>
|
|
||||||
+ </div>
|
|
||||||
+ )}
|
|
||||||
+
|
|
||||||
+ {/* Tab Content - hidden when More screen is shown */}
|
|
||||||
+ {!showMoreScreen && children}
|
|
||||||
+
|
|
||||||
<react_tabs_1.TabsList aria-label="Main" className={native_tabs_module_css_1.default.navigationMenuRoot}>
|
|
||||||
{items}
|
|
||||||
+ {hasOverflow && (
|
|
||||||
+ <button
|
|
||||||
+ type="button"
|
|
||||||
+ className={native_tabs_module_css_1.default.navigationMenuTrigger}
|
|
||||||
+ data-state={showMoreScreen ? "active" : "inactive"}
|
|
||||||
+ onClick={() => setShowMoreScreen(true)}
|
|
||||||
+ >
|
|
||||||
+ <span className={native_tabs_module_css_1.default.tabIcon}>
|
|
||||||
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
+ <circle cx="12" cy="12" r="1"></circle>
|
|
||||||
+ <circle cx="19" cy="12" r="1"></circle>
|
|
||||||
+ <circle cx="5" cy="12" r="1"></circle>
|
|
||||||
+ </svg>
|
|
||||||
+ </span>
|
|
||||||
+ <span className={native_tabs_module_css_1.default.tabText}>More</span>
|
|
||||||
+ </button>
|
|
||||||
+ )}
|
|
||||||
</react_tabs_1.TabsList>
|
|
||||||
- {children}
|
|
||||||
</react_tabs_1.Tabs>);
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+function OverflowTabIcon(props) {
|
|
||||||
+ const { webIcon, webIconFamily, webIconName } = props;
|
|
||||||
+ if (webIconFamily && webIconName) {
|
|
||||||
+ const IconComponent = webIconFamily;
|
|
||||||
+ return <IconComponent name={webIconName} size={20} />;
|
|
||||||
+ } else if (webIcon) {
|
|
||||||
+ return webIcon;
|
|
||||||
+ }
|
|
||||||
+ return null;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
function TabItem(props) {
|
|
||||||
- const { title, badgeValue, route } = props;
|
|
||||||
+ const { title, badgeValue, route, webIcon, webIconFamily, webIconName, webIconColor, webIconSelectedColor, webLabelColor, webLabelSelectedColor, onClick, forceInactive, isActive } = props;
|
|
||||||
const isBadgeEmpty = badgeValue === ' ';
|
|
||||||
- return (<react_tabs_1.TabsTrigger value={route.name} className={native_tabs_module_css_1.default.navigationMenuTrigger}>
|
|
||||||
- <span className={native_tabs_module_css_1.default.tabText}>{title}</span>
|
|
||||||
+ const dataState = forceInactive ? "inactive" : (isActive ? "active" : "inactive");
|
|
||||||
+
|
|
||||||
+ // Resolve colors based on active state
|
|
||||||
+ const resolvedIconColor = isActive && webIconSelectedColor ? webIconSelectedColor : webIconColor;
|
|
||||||
+ const resolvedLabelColor = isActive && webLabelSelectedColor ? webLabelSelectedColor : webLabelColor;
|
|
||||||
+
|
|
||||||
+ const iconStyle = resolvedIconColor ? { color: resolvedIconColor } : {};
|
|
||||||
+ const labelStyle = resolvedLabelColor ? { color: resolvedLabelColor } : {};
|
|
||||||
+
|
|
||||||
+ let iconElement = null;
|
|
||||||
+ if (webIconFamily && webIconName) {
|
|
||||||
+ const IconComponent = webIconFamily;
|
|
||||||
+ iconElement = (<span className={native_tabs_module_css_1.default.tabIcon} style={iconStyle}><IconComponent name={webIconName} size={18} color={resolvedIconColor} /></span>);
|
|
||||||
+ } else if (webIcon) {
|
|
||||||
+ iconElement = (<span className={native_tabs_module_css_1.default.tabIcon} style={iconStyle}>{webIcon}</span>);
|
|
||||||
+ }
|
|
||||||
+ return (<react_tabs_1.TabsTrigger value={route.name} className={native_tabs_module_css_1.default.navigationMenuTrigger} onClick={onClick} data-state={dataState}>
|
|
||||||
+ {iconElement}
|
|
||||||
+ <span className={native_tabs_module_css_1.default.tabText} style={labelStyle}>{title}</span>
|
|
||||||
{badgeValue && (<div className={`${native_tabs_module_css_1.default.tabBadge} ${isBadgeEmpty ? native_tabs_module_css_1.default.emptyTabBadge : ''}`}>
|
|
||||||
{badgeValue}
|
|
||||||
</div>)}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,30 +0,0 @@
|
|||||||
diff --git a/index.js b/index.js
|
|
||||||
index c737c93..f3edd25 100644
|
|
||||||
--- a/index.js
|
|
||||||
+++ b/index.js
|
|
||||||
@@ -336,6 +336,9 @@ module.exports = {
|
|
||||||
return require('./src/private/components/virtualview/VirtualView')
|
|
||||||
.VirtualViewMode;
|
|
||||||
},
|
|
||||||
+ get Slider() {
|
|
||||||
+ return require('@react-native-community/slider').default;
|
|
||||||
+ },
|
|
||||||
// #endregion
|
|
||||||
} as ReactNativePublicAPI;
|
|
||||||
|
|
||||||
@@ -405,15 +408,4 @@ if (__DEV__) {
|
|
||||||
* attempting to access Slider. */
|
|
||||||
/* $FlowFixMe[invalid-export] This is intentional: Flow will error when
|
|
||||||
* attempting to access Slider. */
|
|
||||||
- Object.defineProperty(module.exports, 'Slider', {
|
|
||||||
- configurable: true,
|
|
||||||
- get() {
|
|
||||||
- invariant(
|
|
||||||
- false,
|
|
||||||
- 'Slider has been removed from react-native core. ' +
|
|
||||||
- "It can now be installed and imported from '@react-native-community/slider' instead of 'react-native'. " +
|
|
||||||
- 'See https://github.com/callstack/react-native-slider',
|
|
||||||
- );
|
|
||||||
- },
|
|
||||||
- });
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
diff --git a/dist/utils/environment.js b/dist/utils/environment.js
|
|
||||||
index 7c5d453..8457e4b 100644
|
|
||||||
--- a/dist/utils/environment.js
|
|
||||||
+++ b/dist/utils/environment.js
|
|
||||||
@@ -31,6 +31,12 @@ exports.shouldUseBrowserMode = shouldUseBrowserMode;
|
|
||||||
* Detects if the app is running in Expo Go
|
|
||||||
*/
|
|
||||||
function isExpoGo() {
|
|
||||||
+ if (!__DEV__) {
|
|
||||||
+ return false;
|
|
||||||
+ }
|
|
||||||
+ if (globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.AnythingLauncherModule) {
|
|
||||||
+ return true;
|
|
||||||
+ }
|
|
||||||
var _a, _b;
|
|
||||||
if (!!react_native_1.NativeModules.RNPurchases) {
|
|
||||||
return false;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
diff --git a/lib/commonjs/utils/environment.js b/lib/commonjs/utils/environment.js
|
|
||||||
index 43e5e6a..b67d36a 100644
|
|
||||||
--- a/lib/commonjs/utils/environment.js
|
|
||||||
+++ b/lib/commonjs/utils/environment.js
|
|
||||||
@@ -31,6 +31,12 @@ function shouldUsePreviewAPIMode() {
|
|
||||||
*/
|
|
||||||
function isExpoGo() {
|
|
||||||
var _globalThis$expo;
|
|
||||||
+ if (!__DEV__) {
|
|
||||||
+ return false;
|
|
||||||
+ }
|
|
||||||
+ if (globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.AnythingLauncherModule) {
|
|
||||||
+ return true;
|
|
||||||
+ }
|
|
||||||
if (!!_reactNative.NativeModules.RNPaywalls && !!_reactNative.NativeModules.RNCustomerCenter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
diff --git a/lib/module/utils/environment.js b/lib/module/utils/environment.js
|
|
||||||
index 435d456..4002fe2 100644
|
|
||||||
--- a/lib/module/utils/environment.js
|
|
||||||
+++ b/lib/module/utils/environment.js
|
|
||||||
@@ -26,6 +26,12 @@ export function shouldUsePreviewAPIMode() {
|
|
||||||
*/
|
|
||||||
function isExpoGo() {
|
|
||||||
var _globalThis$expo;
|
|
||||||
+ if (!__DEV__) {
|
|
||||||
+ return false;
|
|
||||||
+ }
|
|
||||||
+ if (globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.AnythingLauncherModule) {
|
|
||||||
+ return true;
|
|
||||||
+ }
|
|
||||||
if (!!NativeModules.RNPaywalls && !!NativeModules.RNCustomerCenter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
diff --git a/src/utils/environment.ts b/src/utils/environment.ts
|
|
||||||
index 5605bf2..ed86595 100644
|
|
||||||
--- a/src/utils/environment.ts
|
|
||||||
+++ b/src/utils/environment.ts
|
|
||||||
@@ -26,6 +26,7 @@ declare global {
|
|
||||||
var expo: {
|
|
||||||
modules?: {
|
|
||||||
ExpoGo?: boolean;
|
|
||||||
+ AnythingLauncherModule?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -34,6 +35,12 @@ declare global {
|
|
||||||
* Detects if the app is running in Expo Go
|
|
||||||
*/
|
|
||||||
function isExpoGo(): boolean {
|
|
||||||
+ if (!__DEV__) {
|
|
||||||
+ return false;
|
|
||||||
+ }
|
|
||||||
+ if (globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.AnythingLauncherModule) {
|
|
||||||
+ return true;
|
|
||||||
+ }
|
|
||||||
if (!!NativeModules.RNPaywalls && !!NativeModules.RNCustomerCenter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
diff --git a/src/RefreshControl.web.js b/src/RefreshControl.web.js
|
|
||||||
index b2351e6..c638d23 100644
|
|
||||||
--- a/src/RefreshControl.web.js
|
|
||||||
+++ b/src/RefreshControl.web.js
|
|
||||||
@@ -1,5 +1,5 @@
|
|
||||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react'
|
|
||||||
-import { View, Text, PanResponder, Animated, ActivityIndicator, findNodeHandle } from 'react-native'
|
|
||||||
+import { View, Text, PanResponder, Animated, ActivityIndicator } from 'react-native'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
|
|
||||||
const arrowIcon =
|
|
||||||
@@ -77,9 +77,9 @@ export default function RefreshControl({
|
|
||||||
onStartShouldSetPanResponderCapture: () => false,
|
|
||||||
onMoveShouldSetPanResponder: (_,gestureState) => {
|
|
||||||
if (!containerRef.current) return false
|
|
||||||
- const containerDOM = findNodeHandle(containerRef.current)
|
|
||||||
- if (!containerDOM) return false
|
|
||||||
- return containerDOM.children[0].scrollTop === 0
|
|
||||||
+ const scrollContainer = containerRef.current?.firstChild
|
|
||||||
+ if (!scrollContainer) return false
|
|
||||||
+ return scrollContainer.scrollTop === 0
|
|
||||||
&& (Math.abs(gestureState.dy) > Math.abs(gestureState.dx) * 2 && Math.abs(gestureState.vy) > Math.abs(gestureState.vx) * 2.5)
|
|
||||||
},
|
|
||||||
onMoveShouldSetPanResponderCapture: () => false,
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
diff --git a/lib/commonjs/positioner.js b/lib/commonjs/positioner.js
|
|
||||||
index cac0f68..ec816b7 100644
|
|
||||||
--- a/lib/commonjs/positioner.js
|
|
||||||
+++ b/lib/commonjs/positioner.js
|
|
||||||
@@ -55,8 +55,12 @@ const Positioner = ({
|
|
||||||
return {};
|
|
||||||
}, [position, bottom, top, offset]);
|
|
||||||
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
||||||
+ ...(_reactNative.Platform.OS === 'web' ? {
|
|
||||||
+ style: [{ pointerEvents: 'box-none' }, containerStyle, insetValues, style],
|
|
||||||
+ } :{
|
|
||||||
style: [containerStyle, insetValues, style],
|
|
||||||
pointerEvents: "box-none",
|
|
||||||
+ }),
|
|
||||||
...props,
|
|
||||||
children: children
|
|
||||||
});
|
|
||||||
diff --git a/lib/module/positioner.js b/lib/module/positioner.js
|
|
||||||
index 476f6bb..40f1968 100644
|
|
||||||
--- a/lib/module/positioner.js
|
|
||||||
+++ b/lib/module/positioner.js
|
|
||||||
@@ -1,7 +1,7 @@
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
-import { View } from 'react-native';
|
|
||||||
+import { View, Platform } from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { useToastContext } from "./context.js";
|
|
||||||
import { jsx as _jsx } from "react/jsx-runtime";
|
|
||||||
@@ -50,8 +50,12 @@ export const Positioner = ({
|
|
||||||
return {};
|
|
||||||
}, [position, bottom, top, offset]);
|
|
||||||
return /*#__PURE__*/_jsx(View, {
|
|
||||||
+...(Platform.OS === 'web' ? {
|
|
||||||
+ style: [{ pointerEvents: 'box-none' }, containerStyle, insetValues, style],
|
|
||||||
+ } :{
|
|
||||||
style: [containerStyle, insetValues, style],
|
|
||||||
pointerEvents: "box-none",
|
|
||||||
+ }),
|
|
||||||
...props,
|
|
||||||
children: children
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Expo
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
app-example
|
|
||||||
|
|
||||||
caches/
|
|
||||||
public/
|
|
||||||
44
apps/mobile/.gitignore
vendored
44
apps/mobile/.gitignore
vendored
@@ -1,44 +0,0 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Expo
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
app-example
|
|
||||||
|
|
||||||
.env
|
|
||||||
|
|
||||||
.metro-virtual/*
|
|
||||||
|
|
||||||
caches/*
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { App } from 'expo-router/build/qualified-entry';
|
|
||||||
import { ScreenViewTracker } from './src/__create/analytics';
|
|
||||||
|
|
||||||
// Screen-view analytics is mounted here, in the entry, rather than in
|
|
||||||
// app/_layout. The entry is platform scaffold that ships with the template, so
|
|
||||||
// the tracker reaches every app on its next rebuild WITHOUT editing each
|
|
||||||
// project's own _layout (mirrors how App.web.tsx tracks navigation at the
|
|
||||||
// root). usePathname reads expo-router's global store.
|
|
||||||
export default function MobileRoot() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScreenViewTracker />
|
|
||||||
<App />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { usePathname, useRouter } from 'expo-router';
|
|
||||||
import { App } from 'expo-router/build/qualified-entry';
|
|
||||||
import React, { memo, useEffect } from 'react';
|
|
||||||
import './src/__create/polyfills';
|
|
||||||
|
|
||||||
import { ErrorBoundary } from './src/__create/ErrorBoundary';
|
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
||||||
import { Toaster } from 'sonner-native';
|
|
||||||
import { AlertModal } from './polyfills/web/alerts.web';
|
|
||||||
import './global.css';
|
|
||||||
|
|
||||||
const RUNTIME_ERROR_PATTERNS = [
|
|
||||||
/fetch failed/i,
|
|
||||||
/networks*(error|request)/i,
|
|
||||||
/failed to fetch/i,
|
|
||||||
/load failed/i,
|
|
||||||
/ECONNREFUSED/i,
|
|
||||||
/ECONNRESET/i,
|
|
||||||
/ETIMEDOUT/i,
|
|
||||||
/ENOTFOUND/i,
|
|
||||||
/ERR_CONNECTION/i,
|
|
||||||
/aborted/i,
|
|
||||||
/timeout/i,
|
|
||||||
/socket hang up/i,
|
|
||||||
/503\b/,
|
|
||||||
/502\b/,
|
|
||||||
/504\b/,
|
|
||||||
/getaddrinfo/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isRuntimeError(msg: string) {
|
|
||||||
return RUNTIME_ERROR_PATTERNS.some((p) => p.test(msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
function postErrorToParent(message: string, name: string, stack: string) {
|
|
||||||
try {
|
|
||||||
if (window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'sandbox:error:detected',
|
|
||||||
error: { message, name, stack },
|
|
||||||
},
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlobalErrorReporter = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const errorHandler = (event: ErrorEvent) => {
|
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
||||||
console.error(event.error);
|
|
||||||
|
|
||||||
const error = event.error;
|
|
||||||
const message = error?.message || event.message || 'Unknown error';
|
|
||||||
if (!isRuntimeError(message)) {
|
|
||||||
postErrorToParent(message, error?.name || 'Error', error?.stack || '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const unhandledRejectionHandler = (event: PromiseRejectionEvent) => {
|
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
||||||
const reason = event.reason;
|
|
||||||
console.error('Unhandled promise rejection:', reason);
|
|
||||||
|
|
||||||
const message = reason?.message || String(reason || '');
|
|
||||||
if (isRuntimeError(message)) return;
|
|
||||||
const isCodeError =
|
|
||||||
reason instanceof TypeError ||
|
|
||||||
reason instanceof ReferenceError ||
|
|
||||||
reason instanceof SyntaxError ||
|
|
||||||
reason?.code === 'MODULE_RESOLVE_FAILED';
|
|
||||||
if (!isCodeError) return;
|
|
||||||
postErrorToParent(message, reason?.name || 'Error', reason?.stack || '');
|
|
||||||
};
|
|
||||||
window.addEventListener('error', errorHandler);
|
|
||||||
window.addEventListener('unhandledrejection', unhandledRejectionHandler);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('error', errorHandler);
|
|
||||||
window.removeEventListener(
|
|
||||||
'unhandledrejection',
|
|
||||||
unhandledRejectionHandler
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wrapper = memo(() => {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<SafeAreaProvider
|
|
||||||
initialMetrics={{
|
|
||||||
insets: { top: 64, bottom: 34, left: 0, right: 0 },
|
|
||||||
frame: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: typeof window === 'undefined' ? 390 : window.innerWidth,
|
|
||||||
height: typeof window === 'undefined' ? 844 : window.innerHeight,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<App />
|
|
||||||
<GlobalErrorReporter />
|
|
||||||
<Toaster />
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const healthyResponse = {
|
|
||||||
type: 'sandbox:mobile:healthcheck:response',
|
|
||||||
healthy: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const useHandshakeParent = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (event.data.type === 'sandbox:mobile:healthcheck') {
|
|
||||||
window.parent.postMessage(healthyResponse, '*');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
// Immediately respond to the parent window with a healthy response in
|
|
||||||
// case we missed the healthcheck message
|
|
||||||
window.parent.postMessage(healthyResponse, '*');
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handleMessage);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateApp = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
useHandshakeParent();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (
|
|
||||||
event.data.type === 'sandbox:navigation' &&
|
|
||||||
event.data.pathname !== pathname
|
|
||||||
) {
|
|
||||||
router.push(event.data.pathname);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
window.parent.postMessage({ type: 'sandbox:mobile:ready' }, '*');
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handleMessage);
|
|
||||||
};
|
|
||||||
}, [router, pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'sandbox:mobile:navigation',
|
|
||||||
pathname,
|
|
||||||
},
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Wrapper />
|
|
||||||
<AlertModal />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateApp;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
const crypto = require('node:crypto');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { reportErrorToRemote } = require('./report-error-to-remote');
|
|
||||||
|
|
||||||
const VIRTUAL_ROOT = path.join(__dirname, '../.metro-virtual');
|
|
||||||
const VIRTUAL_ROOT_UNRESOLVED = path.join(VIRTUAL_ROOT, 'unresolved');
|
|
||||||
|
|
||||||
const handleResolveRequestError = ({
|
|
||||||
error,
|
|
||||||
context,
|
|
||||||
moduleName,
|
|
||||||
platform,
|
|
||||||
}) => {
|
|
||||||
const errorMessage = `Unable to resolve module '${moduleName}' from '${context.originModulePath}'`;
|
|
||||||
const syntheticError = new Error(errorMessage);
|
|
||||||
syntheticError.stack = error.stack;
|
|
||||||
reportErrorToRemote({ error: syntheticError }).catch((_reportError) => {
|
|
||||||
// no-op
|
|
||||||
});
|
|
||||||
if (process.env.NODE_ENV === 'production') throw error;
|
|
||||||
if (platform === 'android') throw error;
|
|
||||||
if (!__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== 'DEVELOPMENT')
|
|
||||||
throw error;
|
|
||||||
|
|
||||||
// Build a deterministic virtual file path for this failed request
|
|
||||||
const key = `${moduleName}|${context.originModulePath}|${platform}`;
|
|
||||||
const hash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(key)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 16);
|
|
||||||
|
|
||||||
fs.mkdirSync(VIRTUAL_ROOT_UNRESOLVED, { recursive: true });
|
|
||||||
const vfile = path.join(VIRTUAL_ROOT_UNRESOLVED, `throw-${hash}.js`);
|
|
||||||
|
|
||||||
// Serialize a safe payload for the client
|
|
||||||
const payload = {
|
|
||||||
moduleName,
|
|
||||||
from: context.originModulePath,
|
|
||||||
platform,
|
|
||||||
originalMessage: String(
|
|
||||||
error?.message ? error.message : 'Unknown resolve error'
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
'// Auto generated by custom Metro resolver',
|
|
||||||
'(function(){',
|
|
||||||
` var info = ${JSON.stringify(payload)};`,
|
|
||||||
" var msg = 'Unable to resolve \"' + info.moduleName + '\" from \"' + info.from + '\"';",
|
|
||||||
" msg += '\\n\\n' + info.originalMessage;",
|
|
||||||
' var e = new Error(msg);',
|
|
||||||
" e.name = 'ModuleResolveError';",
|
|
||||||
" e.code = 'MODULE_RESOLVE_FAILED';",
|
|
||||||
' throw e;',
|
|
||||||
'})();',
|
|
||||||
'export {};', // keep ESM shape harmlessly
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
// Only write if content changed — avoids bumping mtime and triggering Metro rebuild loop
|
|
||||||
const existingContent = fs.existsSync(vfile) ? fs.readFileSync(vfile, 'utf8') : null;
|
|
||||||
if (existingContent !== code) {
|
|
||||||
fs.writeFileSync(vfile, code, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell Metro to load our thrower as a real source file
|
|
||||||
return {
|
|
||||||
filePath: vfile,
|
|
||||||
type: 'sourceFile',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
handleResolveRequestError,
|
|
||||||
VIRTUAL_ROOT,
|
|
||||||
VIRTUAL_ROOT_UNRESOLVED,
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { serializeError } from "serialize-error";
|
|
||||||
|
|
||||||
export const sendLogsToRemote = async (logs) => {
|
|
||||||
if (
|
|
||||||
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
|
|
||||||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
|
|
||||||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
|
|
||||||
) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch(process.env.EXPO_PUBLIC_LOGS_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectGroupId: process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
|
|
||||||
logs,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
} catch (fetchError) {
|
|
||||||
return { success: false, error: fetchError };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reportErrorToRemote = async ({ error }) => {
|
|
||||||
if (
|
|
||||||
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
|
|
||||||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
|
|
||||||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
|
|
||||||
) {
|
|
||||||
console.debug(
|
|
||||||
"reportErrorToRemote: Missing environment variables for logging endpoint, project group ID, or API key.",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
return sendLogsToRemote([
|
|
||||||
{
|
|
||||||
message: JSON.stringify(serializeError(error)),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level: "error",
|
|
||||||
source: "BUILDER",
|
|
||||||
devServerId: process.env.EXPO_PUBLIC_DEV_SERVER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
jest.mock("serialize-error", () => ({
|
|
||||||
serializeError: jest.fn((err) => ({
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
name: err instanceof Error ? err.name : "Error",
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let sendLogsToRemote;
|
|
||||||
let reportErrorToRemote;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
jest.resetModules();
|
|
||||||
delete process.env.EXPO_PUBLIC_LOGS_ENDPOINT;
|
|
||||||
delete process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
|
||||||
delete process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY;
|
|
||||||
delete process.env.EXPO_PUBLIC_DEV_SERVER_ID;
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
|
|
||||||
// Re-require after mocks are set up
|
|
||||||
jest.doMock("serialize-error", () => ({
|
|
||||||
serializeError: jest.fn((err) => ({
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
name: err instanceof Error ? err.name : "Error",
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
const mod = require("./report-error-to-remote");
|
|
||||||
sendLogsToRemote = mod.sendLogsToRemote;
|
|
||||||
reportErrorToRemote = mod.reportErrorToRemote;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sendLogsToRemote", () => {
|
|
||||||
it("returns success: false when env vars are missing", async () => {
|
|
||||||
const result = await sendLogsToRemote([{ message: "test" }]);
|
|
||||||
expect(result).toEqual({ success: false });
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends logs to the endpoint with correct auth header", async () => {
|
|
||||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
|
||||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
|
||||||
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
|
||||||
|
|
||||||
const logs = [
|
|
||||||
{ message: "hello", level: "info", timestamp: "2026-01-01T00:00:00Z" },
|
|
||||||
];
|
|
||||||
const result = await sendLogsToRemote(logs);
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith("https://logs.test/ingest", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: "Bearer key-abc",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ projectGroupId: "pg-123", logs }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns success: false on non-ok response", async () => {
|
|
||||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
|
||||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
|
||||||
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
|
||||||
|
|
||||||
const result = await sendLogsToRemote([{ message: "fail" }]);
|
|
||||||
expect(result).toEqual({ success: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns success: false with error on network failure", async () => {
|
|
||||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
|
||||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
|
||||||
|
|
||||||
const networkError = new Error("Network request failed");
|
|
||||||
global.fetch = jest.fn().mockRejectedValue(networkError);
|
|
||||||
|
|
||||||
const result = await sendLogsToRemote([{ message: "fail" }]);
|
|
||||||
expect(result).toEqual({ success: false, error: networkError });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reportErrorToRemote", () => {
|
|
||||||
it("returns success: false when env vars are missing", async () => {
|
|
||||||
const result = await reportErrorToRemote({
|
|
||||||
error: new Error("test error"),
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false });
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes error and sends as a single log entry with source BUILDER", async () => {
|
|
||||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
|
||||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
|
||||||
process.env.EXPO_PUBLIC_DEV_SERVER_ID = "ds-456";
|
|
||||||
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
|
||||||
|
|
||||||
const error = new Error("something broke");
|
|
||||||
await reportErrorToRemote({ error });
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
||||||
expect(body.projectGroupId).toBe("pg-123");
|
|
||||||
expect(body.logs).toHaveLength(1);
|
|
||||||
expect(body.logs[0].level).toBe("error");
|
|
||||||
expect(body.logs[0].source).toBe("BUILDER");
|
|
||||||
expect(body.logs[0].devServerId).toBe("ds-456");
|
|
||||||
expect(body.logs[0].message).toContain("something broke");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/react-native";
|
|
||||||
import { sendLogsToRemote } from "./report-error-to-remote";
|
|
||||||
|
|
||||||
function isActive(): boolean {
|
|
||||||
return (
|
|
||||||
!__DEV__ &&
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT" &&
|
|
||||||
!!process.env.EXPO_PUBLIC_SENTRY_DSN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
// Mirror a Sentry event into the Anything logs pipeline so native and JS
|
|
||||||
// crashes — including startup crashes that Sentry caches natively and reports
|
|
||||||
// on the next launch — surface in the Flux builder, not only the Sentry
|
|
||||||
// dashboard.
|
|
||||||
function forwardEventToRemote(event: Sentry.Event): void {
|
|
||||||
try {
|
|
||||||
const exception = event.exception?.values?.[0];
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (exception && (exception.type || exception.value)) {
|
|
||||||
lines.push(`${exception.type ?? "Error"}: ${exception.value ?? ""}`);
|
|
||||||
} else if (typeof event.message === "string") {
|
|
||||||
lines.push(event.message);
|
|
||||||
}
|
|
||||||
const frames = exception?.stacktrace?.frames;
|
|
||||||
if (frames && frames.length > 0) {
|
|
||||||
lines.push(
|
|
||||||
frames
|
|
||||||
.slice(-20)
|
|
||||||
.reverse()
|
|
||||||
.map(
|
|
||||||
(frame) =>
|
|
||||||
` at ${frame.function ?? "?"} (${frame.filename ?? "?"}:${frame.lineno ?? 0})`,
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const message = lines.join("\n").trim();
|
|
||||||
if (!message) return;
|
|
||||||
const timestamp =
|
|
||||||
typeof event.timestamp === "number"
|
|
||||||
? new Date(event.timestamp * 1000).toISOString()
|
|
||||||
: new Date().toISOString();
|
|
||||||
sendLogsToRemote([
|
|
||||||
{
|
|
||||||
message: `[SENTRY] ${message}`,
|
|
||||||
timestamp,
|
|
||||||
level: "error",
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initSentry(): void {
|
|
||||||
try {
|
|
||||||
if (!isActive() || initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
Sentry.init({
|
|
||||||
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
|
|
||||||
enableNativeCrashHandling: true,
|
|
||||||
beforeSend: (event) => {
|
|
||||||
forwardEventToRemote(event);
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
|
||||||
if (projectGroupId) {
|
|
||||||
Sentry.setTag("projectGroupId", projectGroupId);
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent — Sentry must never crash the host app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
let mockSendLogsToRemote: jest.Mock;
|
|
||||||
let mockGetItem: jest.Mock;
|
|
||||||
let mockSetItem: jest.Mock;
|
|
||||||
let mockRemoveItem: jest.Mock;
|
|
||||||
let mockFileStore: Record<string, string>;
|
|
||||||
let capturedErrorHandler: ((error: Error, isFatal?: boolean) => void) | null;
|
|
||||||
let originalErrorUtils: unknown;
|
|
||||||
|
|
||||||
const CRASH_FILE = "/doc/testflight_crash_logs.json";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "testflight_logger_pending_logs";
|
|
||||||
let originalDev: boolean;
|
|
||||||
|
|
||||||
function setDevMode(value: boolean) {
|
|
||||||
(globalThis as Record<string, unknown>).__DEV__ = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalDev = (globalThis as Record<string, unknown>).__DEV__ as boolean;
|
|
||||||
jest.resetModules();
|
|
||||||
jest.useFakeTimers();
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV = "PRODUCTION";
|
|
||||||
|
|
||||||
mockSendLogsToRemote = jest.fn().mockResolvedValue({ success: true });
|
|
||||||
mockGetItem = jest.fn().mockResolvedValue(null);
|
|
||||||
mockSetItem = jest.fn().mockResolvedValue(undefined);
|
|
||||||
mockRemoveItem = jest.fn().mockResolvedValue(undefined);
|
|
||||||
mockFileStore = {};
|
|
||||||
|
|
||||||
capturedErrorHandler = null;
|
|
||||||
originalErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils;
|
|
||||||
(globalThis as Record<string, unknown>).ErrorUtils = {
|
|
||||||
getGlobalHandler: () => () => {},
|
|
||||||
setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => {
|
|
||||||
capturedErrorHandler = handler;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.doMock("./report-error-to-remote", () => ({
|
|
||||||
sendLogsToRemote: mockSendLogsToRemote,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.doMock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
getItem: mockGetItem,
|
|
||||||
setItem: mockSetItem,
|
|
||||||
removeItem: mockRemoveItem,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.doMock("expo-file-system", () => {
|
|
||||||
class MockFile {
|
|
||||||
uri: string;
|
|
||||||
constructor(directory: string, name: string) {
|
|
||||||
this.uri = `${directory}/${name}`;
|
|
||||||
}
|
|
||||||
get exists() {
|
|
||||||
return Object.prototype.hasOwnProperty.call(mockFileStore, this.uri);
|
|
||||||
}
|
|
||||||
create() {
|
|
||||||
if (!(this.uri in mockFileStore)) mockFileStore[this.uri] = "";
|
|
||||||
}
|
|
||||||
delete() {
|
|
||||||
delete mockFileStore[this.uri];
|
|
||||||
}
|
|
||||||
write(content: string) {
|
|
||||||
mockFileStore[this.uri] = content;
|
|
||||||
}
|
|
||||||
textSync() {
|
|
||||||
return mockFileStore[this.uri] ?? "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { File: MockFile, Paths: { document: "/doc" } };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
setDevMode(originalDev);
|
|
||||||
delete process.env.EXPO_PUBLIC_CREATE_ENV;
|
|
||||||
(globalThis as Record<string, unknown>).ErrorUtils = originalErrorUtils;
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadModule() {
|
|
||||||
return require("./testflight-logger") as typeof import("./testflight-logger");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("initTestFlightLogger", () => {
|
|
||||||
it("is a no-op when __DEV__ is true", async () => {
|
|
||||||
setDevMode(true);
|
|
||||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
|
|
||||||
expect(getTestFlightLogger()).toBeNull();
|
|
||||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when EXPO_PUBLIC_CREATE_ENV is DEVELOPMENT", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV = "DEVELOPMENT";
|
|
||||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
|
|
||||||
expect(getTestFlightLogger()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("activates when not in dev mode", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
const logger = getTestFlightLogger();
|
|
||||||
expect(logger).not.toBeNull();
|
|
||||||
expect(logger).toHaveProperty("logError");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only creates one instance on multiple calls", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
const first = getTestFlightLogger();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
const second = getTestFlightLogger();
|
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("console patching", () => {
|
|
||||||
it("intercepts console.log and buffers the message", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.log("test message");
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(5_000);
|
|
||||||
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
|
||||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
|
||||||
const logMessage = logs.find(
|
|
||||||
(l: Record<string, string>) => l.message === "test message",
|
|
||||||
);
|
|
||||||
expect(logMessage).toBeDefined();
|
|
||||||
expect(logMessage.level).toBe("log");
|
|
||||||
expect(logMessage.source).toBe("TEST_FLIGHT");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("intercepts console.error", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.error("bad thing");
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(5_000);
|
|
||||||
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
|
||||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
|
||||||
const errorLog = logs.find(
|
|
||||||
(l: Record<string, string>) => l.message === "bad thing",
|
|
||||||
);
|
|
||||||
expect(errorLog).toBeDefined();
|
|
||||||
expect(errorLog.level).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes non-string arguments as JSON", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.log("count:", { x: 1 });
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(5_000);
|
|
||||||
|
|
||||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
|
||||||
const entry = logs.find((l: Record<string, string>) =>
|
|
||||||
l.message.includes("count:"),
|
|
||||||
);
|
|
||||||
expect(entry.message).toBe('count: {"x":1}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("logError", () => {
|
|
||||||
it("immediately flushes error entries", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
const logger = getTestFlightLogger()!;
|
|
||||||
logger.logError("critical failure");
|
|
||||||
|
|
||||||
// Should flush without waiting for the 5s interval
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
|
||||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
|
||||||
expect(logs[0].message).toBe("critical failure");
|
|
||||||
expect(logs[0].level).toBe("error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buffering and flushing", () => {
|
|
||||||
it("flushes buffer every 5 seconds", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.log("entry 1");
|
|
||||||
|
|
||||||
// Not flushed yet at 3 seconds
|
|
||||||
await jest.advanceTimersByTimeAsync(3_000);
|
|
||||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Flushed at 5 seconds
|
|
||||||
await jest.advanceTimersByTimeAsync(2_000);
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-flushes when buffer reaches 50 entries", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
console.log(`entry ${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("persistence and retry", () => {
|
|
||||||
it("persists logs to AsyncStorage when flush fails", async () => {
|
|
||||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.log("will fail");
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(5_000);
|
|
||||||
|
|
||||||
expect(mockSetItem).toHaveBeenCalledWith(
|
|
||||||
STORAGE_KEY,
|
|
||||||
expect.stringContaining("will fail"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores persisted logs on startup and re-sends them", async () => {
|
|
||||||
const persistedLogs = [
|
|
||||||
{
|
|
||||||
message: "old log",
|
|
||||||
timestamp: "2026-01-01T00:00:00Z",
|
|
||||||
level: "error",
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
sessionId: "old-session",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockGetItem.mockResolvedValueOnce(JSON.stringify(persistedLogs));
|
|
||||||
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
expect(mockRemoveItem).toHaveBeenCalledWith(STORAGE_KEY);
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalledWith(persistedLogs);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-persists restored logs if resend also fails", async () => {
|
|
||||||
const persistedLogs = [
|
|
||||||
{
|
|
||||||
message: "stubborn log",
|
|
||||||
timestamp: "2026-01-01T00:00:00Z",
|
|
||||||
level: "error",
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
sessionId: "old-session",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockGetItem
|
|
||||||
.mockResolvedValueOnce(JSON.stringify(persistedLogs))
|
|
||||||
.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
mockSendLogsToRemote.mockResolvedValueOnce({ success: false });
|
|
||||||
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
expect(mockSetItem).toHaveBeenCalledWith(
|
|
||||||
STORAGE_KEY,
|
|
||||||
expect.stringContaining("stubborn log"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps persisted entries at 200", async () => {
|
|
||||||
const existingLogs = Array.from({ length: 195 }, (_, i) => ({
|
|
||||||
message: `existing ${i}`,
|
|
||||||
timestamp: "2026-01-01T00:00:00Z",
|
|
||||||
level: "log",
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
sessionId: "s",
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
|
||||||
mockGetItem
|
|
||||||
.mockResolvedValueOnce(null)
|
|
||||||
.mockResolvedValueOnce(JSON.stringify(existingLogs));
|
|
||||||
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
console.log(`new ${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await jest.advanceTimersByTimeAsync(5_000);
|
|
||||||
|
|
||||||
const setItemCalls = mockSetItem.mock.calls;
|
|
||||||
const lastCall = setItemCalls[setItemCalls.length - 1];
|
|
||||||
const saved = JSON.parse(lastCall[1]);
|
|
||||||
expect(saved.length).toBeLessThanOrEqual(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("crash persistence", () => {
|
|
||||||
it("synchronously snapshots the buffer to the crash file on a fatal error", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
console.log("breadcrumb before crash");
|
|
||||||
capturedErrorHandler!(new Error("startup boom"), true);
|
|
||||||
|
|
||||||
const crashFile = mockFileStore[CRASH_FILE];
|
|
||||||
expect(crashFile).toContain("startup boom");
|
|
||||||
expect(crashFile).toContain("breadcrumb before crash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not snapshot the crash file for a non-fatal error", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
capturedErrorHandler!(new Error("recoverable"), false);
|
|
||||||
|
|
||||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures the in-flight batch when a fatal error triggers auto-flush", async () => {
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
// One short of the auto-flush threshold; the fatal error is the 50th
|
|
||||||
// entry, so addEntry's auto-flush empties `buffer` before
|
|
||||||
// persistBufferSync runs.
|
|
||||||
for (let i = 0; i < 49; i++) {
|
|
||||||
console.log(`entry ${i}`);
|
|
||||||
}
|
|
||||||
capturedErrorHandler!(new Error("boundary crash"), true);
|
|
||||||
|
|
||||||
const crashFile = mockFileStore[CRASH_FILE];
|
|
||||||
expect(crashFile).toContain("boundary crash");
|
|
||||||
expect(crashFile).toContain("entry 0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ships crash-file logs on the next startup and clears the file", async () => {
|
|
||||||
mockFileStore[CRASH_FILE] = JSON.stringify([
|
|
||||||
{
|
|
||||||
message: "[FATAL] crashed last run",
|
|
||||||
timestamp: "2026-01-01T00:00:00Z",
|
|
||||||
level: "error",
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
sessionId: "prev",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setDevMode(false);
|
|
||||||
const { initTestFlightLogger } = loadModule();
|
|
||||||
|
|
||||||
initTestFlightLogger();
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
|
||||||
const sent = mockSendLogsToRemote.mock.calls[0][0];
|
|
||||||
expect(
|
|
||||||
sent.some(
|
|
||||||
(entry: Record<string, string>) =>
|
|
||||||
entry.message === "[FATAL] crashed last run",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { File, Paths } from "expo-file-system";
|
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
|
||||||
import { sendLogsToRemote } from "./report-error-to-remote";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "testflight_logger_pending_logs";
|
|
||||||
// Written synchronously from the crash handlers so logs survive a startup
|
|
||||||
// crash that tears down the JS runtime before the async network / AsyncStorage
|
|
||||||
// paths can finish. Shipped and cleared on the next launch.
|
|
||||||
const CRASH_FILE_NAME = "testflight_crash_logs.json";
|
|
||||||
const MAX_STORED_ENTRIES = 200;
|
|
||||||
const MAX_BUFFER_SIZE = 50;
|
|
||||||
const FLUSH_INTERVAL_MS = 5_000;
|
|
||||||
|
|
||||||
interface LogEntry {
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
level: "log" | "info" | "warn" | "error" | "debug";
|
|
||||||
source: "TEST_FLIGHT";
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActive(): boolean {
|
|
||||||
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSessionId(): string {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let instance: TestFlightLogger | null = null;
|
|
||||||
|
|
||||||
class TestFlightLogger {
|
|
||||||
private buffer: LogEntry[] = [];
|
|
||||||
// Entries spliced out of `buffer` by an in-flight `flush()` that hasn't
|
|
||||||
// confirmed delivery yet. Tracked so a crash mid-flush can still snapshot
|
|
||||||
// them — `buffer` alone would miss them.
|
|
||||||
private inFlightBatch: LogEntry[] = [];
|
|
||||||
private sessionId: string;
|
|
||||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private originalConsole: Record<string, (...args: unknown[]) => void> = {};
|
|
||||||
private isFlushing = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.sessionId = generateSessionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.restorePersistedLogs();
|
|
||||||
this.patchConsole();
|
|
||||||
this.hookUncaughtExceptions();
|
|
||||||
this.hookUnhandledRejections();
|
|
||||||
this.hookAppState();
|
|
||||||
this.flushTimer = setInterval(() => {
|
|
||||||
this.flush();
|
|
||||||
}, FLUSH_INTERVAL_MS);
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent — the logger must never crash the host app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logError(message: string): void {
|
|
||||||
try {
|
|
||||||
this.addEntry("error", message);
|
|
||||||
this.flush();
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addEntry(level: LogEntry["level"], message: string): void {
|
|
||||||
this.buffer.push({
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
source: "TEST_FLIGHT",
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
});
|
|
||||||
if (this.buffer.length >= MAX_BUFFER_SIZE) {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private patchConsole(): void {
|
|
||||||
const levels = ["log", "info", "warn", "error", "debug"] as const;
|
|
||||||
for (const level of levels) {
|
|
||||||
this.originalConsole[level] = console[level].bind(console);
|
|
||||||
console[level] = (...args: unknown[]) => {
|
|
||||||
try {
|
|
||||||
const message = args
|
|
||||||
.map((arg) => {
|
|
||||||
if (typeof arg === "string") return arg;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(arg);
|
|
||||||
} catch {
|
|
||||||
return String(arg);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
this.addEntry(level, message);
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
this.originalConsole[level]?.(...args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private hookUncaughtExceptions(): void {
|
|
||||||
const ErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils as
|
|
||||||
| {
|
|
||||||
getGlobalHandler: () => (error: Error, isFatal?: boolean) => void;
|
|
||||||
setGlobalHandler: (
|
|
||||||
handler: (error: Error, isFatal?: boolean) => void,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!ErrorUtils) return;
|
|
||||||
|
|
||||||
const previousHandler = ErrorUtils.getGlobalHandler();
|
|
||||||
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
|
|
||||||
try {
|
|
||||||
const tag = isFatal ? "[FATAL]" : "[UNCAUGHT]";
|
|
||||||
this.addEntry("error", `${tag} ${error.message}\n${error.stack ?? ""}`);
|
|
||||||
// Only a fatal error tears down the runtime before the async
|
|
||||||
// flush can finish, so only then is the synchronous crash-file
|
|
||||||
// snapshot needed. Non-fatal errors are delivered by flush /
|
|
||||||
// AsyncStorage retry; snapshotting them would just duplicate
|
|
||||||
// entries on the next launch.
|
|
||||||
if (isFatal) {
|
|
||||||
this.persistBufferSync();
|
|
||||||
}
|
|
||||||
this.flush();
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
previousHandler(error, isFatal);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private hookUnhandledRejections(): void {
|
|
||||||
const previous: ((event: PromiseRejectionEvent) => void) | null =
|
|
||||||
globalThis.onunhandledrejection;
|
|
||||||
globalThis.onunhandledrejection = (event: PromiseRejectionEvent) => {
|
|
||||||
try {
|
|
||||||
const reason =
|
|
||||||
event.reason instanceof Error
|
|
||||||
? `${event.reason.message}\n${event.reason.stack ?? ""}`
|
|
||||||
: String(event.reason);
|
|
||||||
this.addEntry("error", `[UNHANDLED_REJECTION] ${reason}`);
|
|
||||||
this.flush();
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
if (previous) {
|
|
||||||
previous(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private hookAppState(): void {
|
|
||||||
AppState.addEventListener("change", (state: AppStateStatus) => {
|
|
||||||
try {
|
|
||||||
this.addEntry("info", `[APP_STATE] ${state}`);
|
|
||||||
if (state === "background" || state === "inactive") {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async flush(): Promise<void> {
|
|
||||||
if (this.isFlushing || this.buffer.length === 0) return;
|
|
||||||
this.isFlushing = true;
|
|
||||||
const batch = this.buffer.splice(0);
|
|
||||||
this.inFlightBatch = batch;
|
|
||||||
try {
|
|
||||||
const result = await sendLogsToRemote(batch);
|
|
||||||
if (!result.success) {
|
|
||||||
await this.persistLogs(batch);
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
await this.persistLogs(batch);
|
|
||||||
} finally {
|
|
||||||
this.inFlightBatch = [];
|
|
||||||
this.isFlushing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistLogs(logs: LogEntry[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
|
||||||
const existing: LogEntry[] = raw ? JSON.parse(raw) : [];
|
|
||||||
const merged = [...existing, ...logs].slice(-MAX_STORED_ENTRIES);
|
|
||||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronously snapshot pending logs to disk. Called from the crash
|
|
||||||
// handlers before the runtime is torn down, so a startup crash is still
|
|
||||||
// recoverable on the next launch. Includes any in-flight flush batch,
|
|
||||||
// since `addEntry`'s auto-flush may have already emptied `buffer`.
|
|
||||||
private persistBufferSync(): void {
|
|
||||||
try {
|
|
||||||
const pending = [...this.inFlightBatch, ...this.buffer];
|
|
||||||
if (pending.length === 0) return;
|
|
||||||
const merged = [...this.readCrashLogsSync(), ...pending].slice(
|
|
||||||
-MAX_STORED_ENTRIES,
|
|
||||||
);
|
|
||||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
|
||||||
if (file.exists) {
|
|
||||||
file.delete();
|
|
||||||
}
|
|
||||||
file.create();
|
|
||||||
file.write(JSON.stringify(merged));
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent — the logger must never crash the host app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readCrashLogsSync(): LogEntry[] {
|
|
||||||
try {
|
|
||||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
|
||||||
if (!file.exists) return [];
|
|
||||||
const parsed: unknown = JSON.parse(file.textSync());
|
|
||||||
return Array.isArray(parsed) ? (parsed as LogEntry[]) : [];
|
|
||||||
} catch (_err) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearCrashFileSync(): void {
|
|
||||||
try {
|
|
||||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
|
||||||
if (file.exists) {
|
|
||||||
file.delete();
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async restorePersistedLogs(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const crashLogs = this.readCrashLogsSync();
|
|
||||||
if (crashLogs.length > 0) {
|
|
||||||
this.clearCrashFileSync();
|
|
||||||
}
|
|
||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
|
||||||
if (raw) {
|
|
||||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
const storedLogs: LogEntry[] = raw ? JSON.parse(raw) : [];
|
|
||||||
const logs = [...crashLogs, ...storedLogs];
|
|
||||||
if (logs.length === 0) return;
|
|
||||||
const result = await sendLogsToRemote(logs);
|
|
||||||
if (!result.success) {
|
|
||||||
await this.persistLogs(logs);
|
|
||||||
}
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initTestFlightLogger(): void {
|
|
||||||
try {
|
|
||||||
if (!isActive()) return;
|
|
||||||
if (instance) return;
|
|
||||||
instance = new TestFlightLogger();
|
|
||||||
instance.start();
|
|
||||||
} catch (_err) {
|
|
||||||
// Silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTestFlightLogger(): {
|
|
||||||
logError: (message: string) => void;
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
if (!isActive()) return null;
|
|
||||||
return instance;
|
|
||||||
} catch (_err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "Anything mobile app",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/images/icon.png",
|
|
||||||
"userInterfaceStyle": "automatic",
|
|
||||||
"newArchEnabled": true,
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true,
|
|
||||||
"infoPlist": {
|
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
"android.permission.RECORD_AUDIO",
|
|
||||||
"android.permission.MODIFY_AUDIO_SETTINGS"
|
|
||||||
],
|
|
||||||
"package": "xyz.create.CreateExpoEnvironment"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
[
|
|
||||||
"expo-router",
|
|
||||||
{
|
|
||||||
"sitemap": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-splash-screen",
|
|
||||||
{
|
|
||||||
"image": "./assets/images/splash-icon.png",
|
|
||||||
"imageWidth": 200,
|
|
||||||
"resizeMode": "contain"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-audio",
|
|
||||||
[
|
|
||||||
"expo-build-properties",
|
|
||||||
{
|
|
||||||
"ios": {
|
|
||||||
"useFrameworks": "static"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-video",
|
|
||||||
{
|
|
||||||
"supportsBackgroundPlayback": true,
|
|
||||||
"supportsPictureInPicture": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-font",
|
|
||||||
"expo-secure-store",
|
|
||||||
"expo-web-browser",
|
|
||||||
"@sentry/react-native/expo",
|
|
||||||
[
|
|
||||||
"react-native-google-mobile-ads",
|
|
||||||
{
|
|
||||||
"androidAppId": "ca-app-pub-3940256099942544~3347511713",
|
|
||||||
"iosAppId": "ca-app-pub-3940256099942544~1458002511"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"web": {
|
|
||||||
"bundler": "metro",
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
},
|
|
||||||
"experiments": {
|
|
||||||
"typedRoutes": true
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"router": {
|
|
||||||
"origin": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
module.exports = (api) => {
|
|
||||||
api.cache(true);
|
|
||||||
return {
|
|
||||||
presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"cli": {
|
|
||||||
"version": ">= 15.0.15",
|
|
||||||
"appVersionSource": "remote"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"development": {
|
|
||||||
"developmentClient": true,
|
|
||||||
"distribution": "internal",
|
|
||||||
"env": {
|
|
||||||
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"preview": {
|
|
||||||
"distribution": "internal",
|
|
||||||
"env": {
|
|
||||||
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"production": {
|
|
||||||
"autoIncrement": true,
|
|
||||||
"android": {
|
|
||||||
"buildType": "app-bundle"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"submit": {
|
|
||||||
"production": {
|
|
||||||
"android": {
|
|
||||||
"serviceAccountKeyPath": "./google-service-account.json",
|
|
||||||
"track": "internal",
|
|
||||||
"releaseStatus": "draft",
|
|
||||||
"changesNotSentForReview": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import App from './App';
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
8320
apps/mobile/fontawesome.css
vendored
8320
apps/mobile/fontawesome.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "Inter", sans-serif !important;
|
|
||||||
}
|
|
||||||
33
apps/mobile/global.d.ts
vendored
33
apps/mobile/global.d.ts
vendored
@@ -1,33 +0,0 @@
|
|||||||
declare module 'react-native/Libraries/Core/ExceptionsManager' {
|
|
||||||
export function handleException(err: Error, isFatal: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'react-native-safe-area-context/lib/commonjs' {
|
|
||||||
export const SafeAreaView: React.ComponentType<any>;
|
|
||||||
export const SafeAreaProvider: React.ComponentType<any>;
|
|
||||||
export const SafeAreaInsetsContext: React.Context<any>;
|
|
||||||
export const SafeAreaFrameContext: React.Context<any>;
|
|
||||||
export function useSafeAreaInsets(): { top: number; right: number; bottom: number; left: number };
|
|
||||||
export function useSafeAreaFrame(): { x: number; y: number; width: number; height: number };
|
|
||||||
export const initialWindowMetrics: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'react-native-web-refresh-control' {
|
|
||||||
export const RefreshControl: React.ComponentType<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'react-native-web/dist/exports/ScrollView' {
|
|
||||||
const ScrollView: React.ComponentType<any>;
|
|
||||||
export default ScrollView;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@anythingai/app/screens/launcher-menu' {
|
|
||||||
const LauncherMenuContainer: React.ComponentType<any>;
|
|
||||||
export default LauncherMenuContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'lodash' {
|
|
||||||
export function merge<T>(...args: T[]): T;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.css' {}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import ExceptionsManager from "react-native/Libraries/Core/ExceptionsManager";
|
|
||||||
|
|
||||||
if (__DEV__) {
|
|
||||||
ExceptionsManager.handleException = (_error, _isFatal) => {
|
|
||||||
// no-op
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
import "react-native-url-polyfill/auto";
|
|
||||||
import "./src/__create/polyfills";
|
|
||||||
global.Buffer = require("buffer").Buffer;
|
|
||||||
|
|
||||||
import "@expo/metro-runtime";
|
|
||||||
import { AppRegistry, LogBox } from "react-native";
|
|
||||||
import { initSentry } from "./__create/sentry";
|
|
||||||
import { initTestFlightLogger } from "./__create/testflight-logger";
|
|
||||||
import { renderRootComponent } from "expo-router/build/renderRootComponent";
|
|
||||||
import App from "./entrypoint";
|
|
||||||
|
|
||||||
initSentry();
|
|
||||||
initTestFlightLogger();
|
|
||||||
|
|
||||||
if (__DEV__ || process.env.EXPO_PUBLIC_CREATE_ENV === "DEVELOPMENT") {
|
|
||||||
LogBox.ignoreAllLogs();
|
|
||||||
LogBox.uninstall();
|
|
||||||
AppRegistry.setWrapperComponentProvider(() => ({ children }) => {
|
|
||||||
return <>{children}</>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
renderRootComponent(App);
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import '@expo/metro-runtime';
|
|
||||||
import { toPng } from 'html-to-image';
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { renderRootComponent } from 'expo-router/build/renderRootComponent';
|
|
||||||
|
|
||||||
import { LoadSkiaWeb } from '@shopify/react-native-skia/lib/module/web';
|
|
||||||
import CreateApp from './App';
|
|
||||||
async function inlineGoogleFonts(): Promise<void> {
|
|
||||||
// Find all <link> elements that load Google Fonts CSS
|
|
||||||
const links = Array.from(document.querySelectorAll<HTMLLinkElement>(
|
|
||||||
'link[rel="stylesheet"][href*="fonts.googleapis.com"]'
|
|
||||||
));
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
try {
|
|
||||||
const href = link.href;
|
|
||||||
const res = await fetch(href);
|
|
||||||
let cssText = await res.text();
|
|
||||||
|
|
||||||
// Ensure font URLs are absolute
|
|
||||||
cssText = cssText.replace(/url\(([^)]+)\)/g, (match, url) => {
|
|
||||||
const clean = url.replace(/["']/g, "");
|
|
||||||
if (clean.startsWith("http")) {
|
|
||||||
return `url(${clean})`;
|
|
||||||
}
|
|
||||||
return `url(${new URL(clean, href).toString()})`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject <style> with the CSS
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.textContent = cssText;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all fonts to actually load
|
|
||||||
if ("fonts" in document) {
|
|
||||||
await document.fonts.ready;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const waitForScreenshotReady = async () => {
|
|
||||||
const images = Array.from(document.images);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
inlineGoogleFonts(),
|
|
||||||
...images.map(
|
|
||||||
(img) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
img.crossOrigin = "anonymous";
|
|
||||||
if (img.complete) {
|
|
||||||
resolve(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
img.onload = () => resolve(true);
|
|
||||||
img.onerror = () => resolve(true);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// small buffer to ensure rendering is stable
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useHandleScreenshotRequest = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
|
||||||
if (event.data.type === "sandbox:web:screenshot:request") {
|
|
||||||
try {
|
|
||||||
await waitForScreenshotReady();
|
|
||||||
|
|
||||||
const width = window.innerWidth;
|
|
||||||
const height = window.innerHeight;
|
|
||||||
const app = document.querySelector<HTMLElement>('#root')
|
|
||||||
if (!app) {
|
|
||||||
throw new Error("Could not find app element");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = await toPng(app, {
|
|
||||||
cacheBust: true,
|
|
||||||
skipFonts: false,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
style: {
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`,
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{ type: "sandbox:web:screenshot:response", dataUrl },
|
|
||||||
"*"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: "sandbox:web:screenshot:error",
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
"*"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listener = (event: MessageEvent) => { void handleMessage(event); };
|
|
||||||
window.addEventListener("message", listener);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("message", listener);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
const CreateAppWithFonts = () => {
|
|
||||||
useHandleScreenshotRequest();
|
|
||||||
return <CreateApp />;
|
|
||||||
|
|
||||||
}
|
|
||||||
LoadSkiaWeb({
|
|
||||||
locateFile: (file: string) => `/${file}`,
|
|
||||||
}).then(async () => {
|
|
||||||
renderRootComponent(CreateAppWithFonts)
|
|
||||||
}).catch(() => {
|
|
||||||
renderRootComponent(CreateAppWithFonts)
|
|
||||||
});
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
|
||||||
const path = require("node:path");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const { FileStore } = require("metro-cache");
|
|
||||||
const { reportErrorToRemote } = require("./__create/report-error-to-remote");
|
|
||||||
const {
|
|
||||||
handleResolveRequestError,
|
|
||||||
VIRTUAL_ROOT,
|
|
||||||
VIRTUAL_ROOT_UNRESOLVED,
|
|
||||||
} = require("./__create/handle-resolve-request-error");
|
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
|
||||||
const config = getDefaultConfig(__dirname);
|
|
||||||
|
|
||||||
config.maxWorkers = 6;
|
|
||||||
|
|
||||||
const WEB_ALIASES = {
|
|
||||||
"expo-secure-store": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/secureStore.web.ts",
|
|
||||||
),
|
|
||||||
"react-native-webview": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/webview.web.tsx",
|
|
||||||
),
|
|
||||||
"react-native-safe-area-context": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/safeAreaContext.web.tsx",
|
|
||||||
),
|
|
||||||
"react-native-maps": path.resolve(__dirname, "./polyfills/web/maps.web.tsx"),
|
|
||||||
"react-native-web/dist/exports/SafeAreaView": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/SafeAreaView.web.tsx",
|
|
||||||
),
|
|
||||||
"react-native-web/dist/exports/Alert": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/alerts.web.tsx",
|
|
||||||
),
|
|
||||||
"react-native-web/dist/exports/RefreshControl": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/refreshControl.web.tsx",
|
|
||||||
),
|
|
||||||
"expo-status-bar": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/statusBar.web.tsx",
|
|
||||||
),
|
|
||||||
"expo-location": path.resolve(__dirname, "./polyfills/web/location.web.ts"),
|
|
||||||
"./layouts/Tabs": path.resolve(__dirname, "./polyfills/web/tabbar.web.tsx"),
|
|
||||||
"expo-notifications": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/notifications.web.tsx",
|
|
||||||
),
|
|
||||||
"expo-contacts": path.resolve(__dirname, "./polyfills/web/contacts.web.ts"),
|
|
||||||
"expo-font": path.resolve(__dirname, "./polyfills/web/expo-font.web.ts"),
|
|
||||||
"react-native-google-mobile-ads": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/google-mobile-ads.web.tsx",
|
|
||||||
),
|
|
||||||
"react-native-web/dist/exports/ScrollView": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/scrollview.web.tsx",
|
|
||||||
),
|
|
||||||
"expo-haptics": path.resolve(__dirname, "./polyfills/web/haptics.web.ts"),
|
|
||||||
"expo-clipboard": path.resolve(__dirname, "./polyfills/web/clipboard.web.ts"),
|
|
||||||
"expo-camera": path.resolve(__dirname, "./polyfills/web/camera.web.tsx"),
|
|
||||||
"expo-image-picker": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/imagePicker.web.ts",
|
|
||||||
),
|
|
||||||
"expo-linking": path.resolve(__dirname, "./polyfills/web/linking.web.ts"),
|
|
||||||
"expo-web-browser": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/webBrowser.web.ts",
|
|
||||||
),
|
|
||||||
"expo-document-picker": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/web/documentPicker.web.ts",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const NATIVE_ALIASES = {
|
|
||||||
"./Libraries/Components/TextInput/TextInput": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/native/textinput.native.tsx",
|
|
||||||
),
|
|
||||||
"react-native-google-mobile-ads": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/native/google-mobile-ads.native.tsx",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
// Aliases that only apply outside production. The real packages crash on
|
|
||||||
// import in Expo Go preview (their browser-mode shims pull in DOM-only code
|
|
||||||
// that throws on Hermes), which makes expo-router silently swallow the load
|
|
||||||
// error and warn "Route is missing the required default export" — leaving
|
|
||||||
// the app on a black/splash screen. EAS production builds keep the real
|
|
||||||
// modules so paid users hit the native SDKs as normal.
|
|
||||||
const DEV_ONLY_NATIVE_ALIASES = {
|
|
||||||
"react-native-purchases": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/native/react-native-purchases.native.tsx",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const SHARED_ALIASES = {
|
|
||||||
"expo-image": path.resolve(__dirname, "./polyfills/shared/expo-image.tsx"),
|
|
||||||
};
|
|
||||||
fs.mkdirSync(VIRTUAL_ROOT_UNRESOLVED, { recursive: true });
|
|
||||||
config.watchFolders = [
|
|
||||||
...config.watchFolders,
|
|
||||||
VIRTUAL_ROOT,
|
|
||||||
VIRTUAL_ROOT_UNRESOLVED,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add web-specific alias configuration through resolveRequest
|
|
||||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
|
||||||
try {
|
|
||||||
// Polyfills are not resolved by Metro
|
|
||||||
if (
|
|
||||||
context.originModulePath.startsWith(`${__dirname}/polyfills/native`) ||
|
|
||||||
context.originModulePath.startsWith(`${__dirname}/polyfills/web`) ||
|
|
||||||
context.originModulePath.startsWith(`${__dirname}/polyfills/shared`)
|
|
||||||
) {
|
|
||||||
return context.resolveRequest(context, moduleName, platform);
|
|
||||||
}
|
|
||||||
// Wildcard alias for Expo Google Fonts
|
|
||||||
if (
|
|
||||||
moduleName.startsWith("@expo-google-fonts/") &&
|
|
||||||
moduleName !== "@expo-google-fonts/dev"
|
|
||||||
) {
|
|
||||||
return context.resolveRequest(
|
|
||||||
context,
|
|
||||||
"@expo-google-fonts/dev",
|
|
||||||
platform,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Resolve AnythingMenu to empty component in production
|
|
||||||
if (moduleName === "./src/__create/anything-menu") {
|
|
||||||
const isProduction = process.env.EXPO_PUBLIC_CREATE_ENV === "PRODUCTION";
|
|
||||||
if (isProduction) {
|
|
||||||
// Create empty component for production
|
|
||||||
const emptyComponentPath = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"./polyfills/shared/empty-component.tsx",
|
|
||||||
);
|
|
||||||
return context.resolveRequest(context, emptyComponentPath, platform);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (SHARED_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
|
|
||||||
return context.resolveRequest(
|
|
||||||
context,
|
|
||||||
SHARED_ALIASES[moduleName],
|
|
||||||
platform,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (platform === "web") {
|
|
||||||
// Only apply aliases if the module is one of our polyfills
|
|
||||||
if (WEB_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
|
|
||||||
return context.resolveRequest(
|
|
||||||
context,
|
|
||||||
WEB_ALIASES[moduleName],
|
|
||||||
platform,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context.resolveRequest(context, moduleName, platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (NATIVE_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
|
|
||||||
return context.resolveRequest(
|
|
||||||
context,
|
|
||||||
NATIVE_ALIASES[moduleName],
|
|
||||||
platform,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
DEV_ONLY_NATIVE_ALIASES[moduleName] &&
|
|
||||||
!moduleName.startsWith("./polyfills/") &&
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV !== "PRODUCTION"
|
|
||||||
) {
|
|
||||||
return context.resolveRequest(
|
|
||||||
context,
|
|
||||||
DEV_ONLY_NATIVE_ALIASES[moduleName],
|
|
||||||
platform,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context.resolveRequest(context, moduleName, platform);
|
|
||||||
} catch (error) {
|
|
||||||
return handleResolveRequestError({ error, context, platform, moduleName });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheDir = path.join(__dirname, "caches");
|
|
||||||
|
|
||||||
config.cacheStores = () => [
|
|
||||||
new FileStore({
|
|
||||||
root: path.join(cacheDir, ".metro-cache"),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
config.resetCache = false;
|
|
||||||
config.fileMapCacheDirectory = cacheDir;
|
|
||||||
config.reporter = {
|
|
||||||
...config.reporter,
|
|
||||||
update: (event) => {
|
|
||||||
config.reporter?.update(event);
|
|
||||||
const reportableErrors = [
|
|
||||||
"error",
|
|
||||||
"bundling_error",
|
|
||||||
"cache_read_error",
|
|
||||||
"hmr_client_error",
|
|
||||||
"transformer_load_failed",
|
|
||||||
];
|
|
||||||
for (const errorType of reportableErrors) {
|
|
||||||
if (event.type === errorType) {
|
|
||||||
reportErrorToRemote({ error: event.error }).catch((_reportError) => {
|
|
||||||
// no-op
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalGetTransformOptions = config.transformer.getTransformOptions;
|
|
||||||
|
|
||||||
config.transformer = {
|
|
||||||
...config.transformer,
|
|
||||||
getTransformOptions: async (entryPoints, options) => {
|
|
||||||
if (options.dev === false) {
|
|
||||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(cacheDir);
|
|
||||||
}
|
|
||||||
return await originalGetTransformOptions(entryPoints, options);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mobile",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "index",
|
|
||||||
"scripts": {
|
|
||||||
"eas-build-pre-install": "corepack enable && corepack prepare yarn@4.12.0 --activate && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@anythingai/app": "0.1.96",
|
|
||||||
"@expo-google-fonts/dev": "0.4.7",
|
|
||||||
"@expo-google-fonts/inter": "0.4.2",
|
|
||||||
"@expo/vector-icons": "15.0.3",
|
|
||||||
"@gorhom/bottom-sheet": "5.2.6",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
|
||||||
"@react-native-community/netinfo": "patch:@react-native-community/netinfo@npm%3A11.4.1#~/.yarn/patches/@react-native-community+netinfo+11.4.1.patch",
|
|
||||||
"@react-native-community/slider": "5.0.1",
|
|
||||||
"@react-native-masked-view/masked-view": "0.3.2",
|
|
||||||
"@react-native-picker/picker": "2.11.1",
|
|
||||||
"@react-navigation/bottom-tabs": "7.4.8",
|
|
||||||
"@react-navigation/elements": "2.6.5",
|
|
||||||
"@react-navigation/native": "7.2.2",
|
|
||||||
"@react-navigation/native-stack": "7.3.27",
|
|
||||||
"@sentry/react-native": "8.11.1",
|
|
||||||
"@shopify/react-native-skia": "2.2.12",
|
|
||||||
"@tanstack/react-query": "5.72.2",
|
|
||||||
"@teovilla/react-native-web-maps": "0.9.5",
|
|
||||||
"@uploadcare/upload-client": "6.14.3",
|
|
||||||
"color2k": "2.0.3",
|
|
||||||
"date-fns": "4.1.0",
|
|
||||||
"expo": "54.0.34",
|
|
||||||
"expo-asset": "12.0.13",
|
|
||||||
"expo-audio": "1.1.1",
|
|
||||||
"expo-av": "16.0.8",
|
|
||||||
"expo-battery": "10.0.8",
|
|
||||||
"expo-blur": "15.0.8",
|
|
||||||
"expo-build-properties": "1.0.10",
|
|
||||||
"expo-calendar": "15.0.8",
|
|
||||||
"expo-camera": "17.0.10",
|
|
||||||
"expo-clipboard": "8.0.8",
|
|
||||||
"expo-constants": "18.0.13",
|
|
||||||
"expo-contacts": "15.0.11",
|
|
||||||
"expo-device": "8.0.10",
|
|
||||||
"expo-document-picker": "14.0.8",
|
|
||||||
"expo-file-system": "19.0.22",
|
|
||||||
"expo-font": "14.0.11",
|
|
||||||
"expo-gl": "16.0.10",
|
|
||||||
"expo-glass-effect": "~0.1.10",
|
|
||||||
"expo-haptics": "15.0.8",
|
|
||||||
"expo-image": "3.0.11",
|
|
||||||
"expo-image-manipulator": "14.0.8",
|
|
||||||
"expo-image-picker": "17.0.11",
|
|
||||||
"expo-linear-gradient": "15.0.8",
|
|
||||||
"expo-linking": "8.0.12",
|
|
||||||
"expo-location": "19.0.8",
|
|
||||||
"expo-modules-core": "3.0.30",
|
|
||||||
"expo-notifications": "0.32.17",
|
|
||||||
"expo-router": "patch:expo-router@npm%3A6.0.11#~/.yarn/patches/expo-router+6.0.11.patch",
|
|
||||||
"expo-secure-store": "15.0.8",
|
|
||||||
"expo-sensors": "15.0.8",
|
|
||||||
"expo-speech": "14.0.8",
|
|
||||||
"expo-splash-screen": "31.0.13",
|
|
||||||
"expo-status-bar": "3.0.9",
|
|
||||||
"expo-store-review": "patch:expo-store-review@npm%3A9.0.8#~/.yarn/patches/expo-store-review+9.0.8.patch",
|
|
||||||
"expo-symbols": "1.0.8",
|
|
||||||
"expo-system-ui": "6.0.9",
|
|
||||||
"expo-three": "8.0.0",
|
|
||||||
"expo-updates": "29.0.17",
|
|
||||||
"expo-video": "3.0.16",
|
|
||||||
"expo-web-browser": "15.0.11",
|
|
||||||
"html-to-image": "1.11.13",
|
|
||||||
"lodash": "^4.18.1",
|
|
||||||
"lucide-react-native": "0.525.0",
|
|
||||||
"moti": "0.30.0",
|
|
||||||
"papaparse": "5.5.3",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-native": "patch:react-native@npm%3A0.81.4#~/.yarn/patches/react-native+0.81.4.patch",
|
|
||||||
"react-native-calendars": "https://codeload.github.com/craftworkco/react-native-calendars/tar.gz/ae19e2af74ecdb29d6117ca41fbf41977a10cc23",
|
|
||||||
"react-native-gesture-handler": "2.28.0",
|
|
||||||
"react-native-google-mobile-ads": "15.8.3",
|
|
||||||
"react-native-graph": "1.1.0",
|
|
||||||
"react-native-maps": "1.20.1",
|
|
||||||
"react-native-purchases": "patch:react-native-purchases@npm%3A9.6.1#~/.yarn/patches/react-native-purchases+9.6.1.patch",
|
|
||||||
"react-native-purchases-ui": "patch:react-native-purchases-ui@npm%3A9.6.1#~/.yarn/patches/react-native-purchases-ui+9.6.1.patch",
|
|
||||||
"react-native-reanimated": "4.1.1",
|
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
|
||||||
"react-native-safe-area-context": "5.6.0",
|
|
||||||
"react-native-screen-transitions": "^3.2.1",
|
|
||||||
"react-native-screens": "4.16.0",
|
|
||||||
"react-native-svg": "15.12.1",
|
|
||||||
"react-native-url-polyfill": "2.0.0",
|
|
||||||
"react-native-web": "0.21.0",
|
|
||||||
"react-native-web-refresh-control": "patch:react-native-web-refresh-control@npm%3A1.1.2#~/.yarn/patches/react-native-web-refresh-control+1.1.2.patch",
|
|
||||||
"react-native-webview": "13.15.0",
|
|
||||||
"react-native-worklets": "0.5.1",
|
|
||||||
"serialize-error": "12.0.0",
|
|
||||||
"sonner-native": "patch:sonner-native@npm%3A0.21.0#~/.yarn/patches/sonner-native+0.21.0.patch",
|
|
||||||
"three": "0.166.0",
|
|
||||||
"yup": "1.6.1",
|
|
||||||
"zod": "4.1.11",
|
|
||||||
"zustand": "5.0.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.29.0",
|
|
||||||
"@expo/cli": "patch:@expo/cli@npm%3A54.0.1#~/.yarn/patches/@expo+cli+54.0.1.patch",
|
|
||||||
"@expo/metro-runtime": "patch:@expo/metro-runtime@npm%3A6.1.2#~/.yarn/patches/@expo+metro-runtime+6.1.2.patch",
|
|
||||||
"@tailwindcss/postcss": "4.1.18",
|
|
||||||
"@types/jest": "29.5.14",
|
|
||||||
"@types/react": "19.1.10",
|
|
||||||
"autoprefixer": "10.4.20",
|
|
||||||
"jest": "29.7.0",
|
|
||||||
"jest-expo": "54.0.17",
|
|
||||||
"postcss": "8.5.10",
|
|
||||||
"tailwind-scrollbar": "3.1.0",
|
|
||||||
"tailwindcss": "3",
|
|
||||||
"tailwindcss-animate": "1.0.7",
|
|
||||||
"typescript": "~5.9.2"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@react-navigation/bottom-tabs": "7.4.8",
|
|
||||||
"@react-navigation/core": "7.12.4",
|
|
||||||
"@react-navigation/elements": "2.6.5",
|
|
||||||
"@react-navigation/native": "7.2.2",
|
|
||||||
"@react-navigation/native-stack": "7.3.27",
|
|
||||||
"@react-navigation/routers": "7.5.1",
|
|
||||||
"@react-navigation/stack": "7.4.7"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"preset": "jest-expo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
diff --git a/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm b/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
|
|
||||||
index 0000000..0000000 100644
|
|
||||||
--- a/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
|
|
||||||
+++ b/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
|
|
||||||
@@ -432,14 +432,16 @@ void ObjCTurboModule::performVoidMethodInvocation(
|
|
||||||
TurboModulePerfLogger::asyncMethodCallExecutionStart(moduleName, methodName, asyncCallCounter);
|
|
||||||
}
|
|
||||||
|
|
||||||
@try {
|
|
||||||
[inv invokeWithTarget:strongModule];
|
|
||||||
} @catch (NSException *exception) {
|
|
||||||
- throw convertNSExceptionToJSError(runtime, exception, std::string{moduleName}, methodNameStr);
|
|
||||||
+ // Void methods are always async, re-throw instead of converting to
|
|
||||||
+ // JSError, same as the async branch in performMethodInvocation.
|
|
||||||
+ @throw exception;
|
|
||||||
} @finally {
|
|
||||||
[retainedObjectsForInvocation removeAllObjects];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldVoidMethodsExecuteSync_) {
|
|
||||||
TurboModulePerfLogger::syncMethodCallExecutionEnd(moduleName, methodName);
|
|
||||||
} else {
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Text, View, type ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
// Stub for react-native-google-mobile-ads on web.
|
|
||||||
// Ads are native-only; these render visual placeholders so users can preview
|
|
||||||
// their layouts in Expo Go without the native module.
|
|
||||||
|
|
||||||
export const BannerAdSize = {
|
|
||||||
BANNER: "BANNER",
|
|
||||||
FULL_BANNER: "FULL_BANNER",
|
|
||||||
LARGE_BANNER: "LARGE_BANNER",
|
|
||||||
LEADERBOARD: "LEADERBOARD",
|
|
||||||
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
|
|
||||||
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
|
|
||||||
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
|
|
||||||
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
|
|
||||||
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdEventType = {
|
|
||||||
LOADED: "loaded",
|
|
||||||
ERROR: "error",
|
|
||||||
OPENED: "opened",
|
|
||||||
CLICKED: "clicked",
|
|
||||||
CLOSED: "closed",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedAdEventType = {
|
|
||||||
LOADED: "loaded",
|
|
||||||
EARNED_REWARD: "earned_reward",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdsConsentStatus = {
|
|
||||||
UNKNOWN: 0,
|
|
||||||
REQUIRED: 1,
|
|
||||||
NOT_REQUIRED: 2,
|
|
||||||
OBTAINED: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdsConsentDebugGeography = {
|
|
||||||
DISABLED: 0,
|
|
||||||
EEA: 1,
|
|
||||||
NOT_EEA: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TestIds = {
|
|
||||||
BANNER: "ca-app-pub-3940256099942544/6300978111",
|
|
||||||
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
|
|
||||||
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
|
||||||
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
|
||||||
REWARDED: "ca-app-pub-3940256099942544/5224354917",
|
|
||||||
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
|
|
||||||
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
|
|
||||||
NATIVE: "ca-app-pub-3940256099942544/2247696110",
|
|
||||||
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLACEHOLDER_BG = "#f5f5f5";
|
|
||||||
const PLACEHOLDER_BORDER = "#e0e0e0";
|
|
||||||
const PLACEHOLDER_TEXT = "#999999";
|
|
||||||
const AD_LABEL_BG = "#fbbc04";
|
|
||||||
const AD_LABEL_TEXT = "#1a1a1a";
|
|
||||||
|
|
||||||
const AdLabel = () => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: AD_LABEL_BG,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: AD_LABEL_TEXT,
|
|
||||||
lineHeight: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ad
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getBannerStyle = (size: string | undefined): ViewStyle => {
|
|
||||||
switch (size) {
|
|
||||||
case "FULL_BANNER":
|
|
||||||
return { width: 468, height: 60 };
|
|
||||||
case "LARGE_BANNER":
|
|
||||||
return { width: 320, height: 100 };
|
|
||||||
case "LEADERBOARD":
|
|
||||||
return { width: 728, height: 90 };
|
|
||||||
case "MEDIUM_RECTANGLE":
|
|
||||||
return { width: 300, height: 250 };
|
|
||||||
case "WIDE_SKYSCRAPER":
|
|
||||||
return { width: 160, height: 600 };
|
|
||||||
case "ADAPTIVE_BANNER":
|
|
||||||
case "ANCHORED_ADAPTIVE_BANNER":
|
|
||||||
return { width: "100%", height: 50 };
|
|
||||||
case "INLINE_ADAPTIVE_BANNER":
|
|
||||||
return { width: "100%", height: 100 };
|
|
||||||
default:
|
|
||||||
return { width: 320, height: 50 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type BannerAdProps = {
|
|
||||||
size?: string;
|
|
||||||
unitId?: string;
|
|
||||||
onAdLoaded?: () => void;
|
|
||||||
onAdFailedToLoad?: (error: unknown) => void;
|
|
||||||
onAdOpened?: () => void;
|
|
||||||
onAdClosed?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BannerPlaceholder = ({
|
|
||||||
size,
|
|
||||||
label,
|
|
||||||
}: { size?: string; label: string }) => {
|
|
||||||
const dims = getBannerStyle(size);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
...dims,
|
|
||||||
backgroundColor: PLACEHOLDER_BG,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdLabel />
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BannerAd = ({ size }: BannerAdProps) => (
|
|
||||||
<BannerPlaceholder size={size} label="Banner Ad" />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const GAMBannerAd = ({ size }: BannerAdProps) => (
|
|
||||||
<BannerPlaceholder size={size} label="Ad Manager Banner" />
|
|
||||||
);
|
|
||||||
|
|
||||||
type NativeAdViewProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
nativeAd?: unknown;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultNativeAdContent = () => (
|
|
||||||
<View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
marginRight: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 12,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
width: "70%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "40%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 140,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 10,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
|
||||||
Native Ad Media
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "80%",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
backgroundColor: "#1a73e8",
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
|
|
||||||
Install
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
backgroundColor: PLACEHOLDER_BG,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
style as ViewStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
|
|
||||||
<AdLabel />
|
|
||||||
</View>
|
|
||||||
{children ?? <DefaultNativeAdContent />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAsset = ({
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
assetType?: string;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
}) => <View style={style as ViewStyle}>{children}</View>;
|
|
||||||
|
|
||||||
export const NativeMediaView = ({
|
|
||||||
style,
|
|
||||||
}: { style?: ViewStyle | ViewStyle[] }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: 180,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
style as ViewStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
|
||||||
Ad Media (native only)
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAd = {
|
|
||||||
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
|
|
||||||
headline: "Sample Ad Headline",
|
|
||||||
body: "Native ads only render on a real device.",
|
|
||||||
advertiser: "Sample Advertiser",
|
|
||||||
callToAction: "Install",
|
|
||||||
icon: null,
|
|
||||||
images: [],
|
|
||||||
starRating: null,
|
|
||||||
store: null,
|
|
||||||
price: null,
|
|
||||||
addAdEventListener: () => () => {},
|
|
||||||
removeAllListeners: () => {},
|
|
||||||
destroy: () => {},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFullScreenAdStub = () => ({
|
|
||||||
loaded: false,
|
|
||||||
load: () => {},
|
|
||||||
show: () => Promise.resolve(),
|
|
||||||
addAdEventListener: () => () => {},
|
|
||||||
addAdEventsListener: () => () => {},
|
|
||||||
removeAllListeners: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const InterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppOpenAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMRewardedAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMRewardedInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseHookResult = {
|
|
||||||
isLoaded: false,
|
|
||||||
isOpened: false,
|
|
||||||
isClicked: false,
|
|
||||||
isClosed: false,
|
|
||||||
error: null as unknown,
|
|
||||||
load: () => {},
|
|
||||||
show: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useInterstitialAd = () => ({ ...baseHookResult });
|
|
||||||
export const useAppOpenAd = () => ({ ...baseHookResult });
|
|
||||||
export const useRewardedAd = () => ({
|
|
||||||
...baseHookResult,
|
|
||||||
isEarnedReward: false,
|
|
||||||
reward: null,
|
|
||||||
});
|
|
||||||
export const useRewardedInterstitialAd = () => ({
|
|
||||||
...baseHookResult,
|
|
||||||
isEarnedReward: false,
|
|
||||||
reward: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AdsConsent = {
|
|
||||||
requestInfoUpdate: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
isConsentFormAvailable: false,
|
|
||||||
}),
|
|
||||||
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
|
|
||||||
loadAndShowConsentFormIfRequired: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
}),
|
|
||||||
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
|
|
||||||
reset: () => {},
|
|
||||||
getConsentInfo: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
canRequestAds: true,
|
|
||||||
isConsentFormAvailable: false,
|
|
||||||
privacyOptionsRequirementStatus: "NOT_REQUIRED",
|
|
||||||
}),
|
|
||||||
getUserChoices: async () => ({}),
|
|
||||||
getTCString: async () => "",
|
|
||||||
getGdprApplies: async () => false,
|
|
||||||
getPurposeConsents: async () => "",
|
|
||||||
getPurposeLegitimateInterests: async () => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mobileAdsInstance = {
|
|
||||||
initialize: async () => [],
|
|
||||||
setRequestConfiguration: async () => {},
|
|
||||||
openAdInspector: async () => {},
|
|
||||||
openDebugMenu: () => {},
|
|
||||||
setAppMuted: () => {},
|
|
||||||
setAppVolume: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mobileAds = () => mobileAdsInstance;
|
|
||||||
|
|
||||||
const defaultExport = Object.assign(mobileAds, {
|
|
||||||
BannerAd,
|
|
||||||
GAMBannerAd,
|
|
||||||
BannerAdSize,
|
|
||||||
InterstitialAd,
|
|
||||||
RewardedAd,
|
|
||||||
RewardedInterstitialAd,
|
|
||||||
AppOpenAd,
|
|
||||||
GAMInterstitialAd,
|
|
||||||
GAMRewardedAd,
|
|
||||||
GAMRewardedInterstitialAd,
|
|
||||||
NativeAd,
|
|
||||||
NativeAdView,
|
|
||||||
NativeAsset,
|
|
||||||
NativeMediaView,
|
|
||||||
AdEventType,
|
|
||||||
RewardedAdEventType,
|
|
||||||
AdsConsent,
|
|
||||||
AdsConsentStatus,
|
|
||||||
AdsConsentDebugGeography,
|
|
||||||
TestIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { mobileAds };
|
|
||||||
export default defaultExport;
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// Expo Go-safe stub for react-native-purchases.
|
|
||||||
//
|
|
||||||
// The real package's index pulls in @revenuecat/purchases-js-hybrid-mappings
|
|
||||||
// (a ~15k-line Svelte UMD bundle of browser-only code) which throws on
|
|
||||||
// module evaluation under Hermes in Expo Go preview. Even importing it from
|
|
||||||
// a hook that's never called crashes _layout.tsx, which makes expo-router
|
|
||||||
// silently swallow the throw and warn "Route \"./_layout.tsx\" is missing
|
|
||||||
// the required default export" — leaving the app stuck on a black/splash
|
|
||||||
// screen forever.
|
|
||||||
//
|
|
||||||
// This polyfill is wired up in metro.config.js for native platforms only
|
|
||||||
// when EXPO_PUBLIC_CREATE_ENV !== 'PRODUCTION'. Production EAS builds keep
|
|
||||||
// the real SDK, so paid users hit RevenueCat as normal.
|
|
||||||
|
|
||||||
const noopAsync = async () => undefined;
|
|
||||||
|
|
||||||
const LOG_LEVEL = {
|
|
||||||
VERBOSE: "VERBOSE",
|
|
||||||
DEBUG: "DEBUG",
|
|
||||||
INFO: "INFO",
|
|
||||||
WARN: "WARN",
|
|
||||||
ERROR: "ERROR",
|
|
||||||
SILENT: "SILENT",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRODUCT_CATEGORY = {
|
|
||||||
SUBSCRIPTION: "SUBSCRIPTION",
|
|
||||||
NON_SUBSCRIPTION: "NON_SUBSCRIPTION",
|
|
||||||
UNKNOWN: "UNKNOWN",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PURCHASE_TYPE = {
|
|
||||||
INAPP: "inapp",
|
|
||||||
SUBS: "subs",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PURCHASES_ARE_COMPLETED_BY_TYPE = {
|
|
||||||
REVENUECAT: "REVENUECAT",
|
|
||||||
MY_APP: "MY_APP",
|
|
||||||
};
|
|
||||||
|
|
||||||
const REFUND_REQUEST_STATUS = {
|
|
||||||
SUCCESS: "SUCCESS",
|
|
||||||
USER_CANCELLED: "USER_CANCELLED",
|
|
||||||
ERROR: "ERROR",
|
|
||||||
};
|
|
||||||
|
|
||||||
const BILLING_FEATURE = {
|
|
||||||
SUBSCRIPTIONS: "SUBSCRIPTIONS",
|
|
||||||
SUBSCRIPTIONS_UPDATE: "SUBSCRIPTIONS_UPDATE",
|
|
||||||
IN_APP_MESSAGING: "IN_APP_MESSAGING",
|
|
||||||
PRICE_CHANGE_CONFIRMATION: "PRICE_CHANGE_CONFIRMATION",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STOREKIT_VERSION = {
|
|
||||||
DEFAULT: "DEFAULT",
|
|
||||||
STOREKIT_1: "STOREKIT_1",
|
|
||||||
STOREKIT_2: "STOREKIT_2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const Purchases = {
|
|
||||||
configure: noopAsync,
|
|
||||||
setLogLevel: () => {},
|
|
||||||
setLogHandler: () => {},
|
|
||||||
addCustomerInfoUpdateListener: () => () => {},
|
|
||||||
removeCustomerInfoUpdateListener: () => {},
|
|
||||||
getOfferings: async () => ({ current: null, all: {} }),
|
|
||||||
getProducts: async () => [],
|
|
||||||
getCustomerInfo: async () => ({
|
|
||||||
entitlements: { active: {}, all: {} },
|
|
||||||
activeSubscriptions: [],
|
|
||||||
allPurchasedProductIdentifiers: [],
|
|
||||||
latestExpirationDate: null,
|
|
||||||
firstSeen: new Date().toISOString(),
|
|
||||||
originalAppUserId: "expo-go-preview",
|
|
||||||
requestDate: new Date().toISOString(),
|
|
||||||
allExpirationDates: {},
|
|
||||||
allPurchaseDates: {},
|
|
||||||
originalApplicationVersion: null,
|
|
||||||
originalPurchaseDate: null,
|
|
||||||
managementURL: null,
|
|
||||||
nonSubscriptionTransactions: [],
|
|
||||||
}),
|
|
||||||
purchasePackage: async () => {
|
|
||||||
const error: Error & { userCancelled?: boolean } = new Error(
|
|
||||||
"Purchases not available in Expo Go preview. Build a development build or run in TestFlight to test purchases.",
|
|
||||||
);
|
|
||||||
error.userCancelled = true;
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
purchaseProduct: async () => {
|
|
||||||
const error: Error & { userCancelled?: boolean } = new Error(
|
|
||||||
"Purchases not available in Expo Go preview.",
|
|
||||||
);
|
|
||||||
error.userCancelled = true;
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
restorePurchases: async () => ({
|
|
||||||
entitlements: { active: {}, all: {} },
|
|
||||||
activeSubscriptions: [],
|
|
||||||
allPurchasedProductIdentifiers: [],
|
|
||||||
latestExpirationDate: null,
|
|
||||||
firstSeen: new Date().toISOString(),
|
|
||||||
originalAppUserId: "expo-go-preview",
|
|
||||||
requestDate: new Date().toISOString(),
|
|
||||||
allExpirationDates: {},
|
|
||||||
allPurchaseDates: {},
|
|
||||||
originalApplicationVersion: null,
|
|
||||||
originalPurchaseDate: null,
|
|
||||||
managementURL: null,
|
|
||||||
nonSubscriptionTransactions: [],
|
|
||||||
}),
|
|
||||||
logIn: async (appUserID: string) => ({
|
|
||||||
customerInfo: {
|
|
||||||
entitlements: { active: {}, all: {} },
|
|
||||||
activeSubscriptions: [],
|
|
||||||
originalAppUserId: appUserID,
|
|
||||||
},
|
|
||||||
created: false,
|
|
||||||
}),
|
|
||||||
logOut: async () => ({
|
|
||||||
entitlements: { active: {}, all: {} },
|
|
||||||
activeSubscriptions: [],
|
|
||||||
originalAppUserId: "expo-go-preview",
|
|
||||||
}),
|
|
||||||
setAttributes: noopAsync,
|
|
||||||
setEmail: noopAsync,
|
|
||||||
setDisplayName: noopAsync,
|
|
||||||
setPhoneNumber: noopAsync,
|
|
||||||
setPushToken: noopAsync,
|
|
||||||
setAdjustID: noopAsync,
|
|
||||||
setAppsflyerID: noopAsync,
|
|
||||||
setFBAnonymousID: noopAsync,
|
|
||||||
setMparticleID: noopAsync,
|
|
||||||
setOnesignalID: noopAsync,
|
|
||||||
setAirshipChannelID: noopAsync,
|
|
||||||
setMediaSource: noopAsync,
|
|
||||||
setCampaign: noopAsync,
|
|
||||||
setAdGroup: noopAsync,
|
|
||||||
setAd: noopAsync,
|
|
||||||
setKeyword: noopAsync,
|
|
||||||
setCreative: noopAsync,
|
|
||||||
collectDeviceIdentifiers: () => {},
|
|
||||||
syncPurchases: noopAsync,
|
|
||||||
syncAttributesAndOfferingsIfNeeded: async () => ({ current: null, all: {} }),
|
|
||||||
enableAdServicesAttributionTokenCollection: () => {},
|
|
||||||
isAnonymous: async () => true,
|
|
||||||
checkTrialOrIntroductoryPriceEligibility: async () => ({}),
|
|
||||||
invalidateCustomerInfoCache: () => {},
|
|
||||||
presentCodeRedemptionSheet: () => {},
|
|
||||||
beginRefundRequestForActiveEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
|
|
||||||
beginRefundRequestForEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
|
|
||||||
beginRefundRequestForProduct: async () => REFUND_REQUEST_STATUS.ERROR,
|
|
||||||
showInAppMessages: noopAsync,
|
|
||||||
getPromotionalOffer: async () => null,
|
|
||||||
purchasePromotionalOffer: async () => {
|
|
||||||
const error: Error & { userCancelled?: boolean } = new Error(
|
|
||||||
"Purchases not available in Expo Go preview.",
|
|
||||||
);
|
|
||||||
error.userCancelled = true;
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
canMakePayments: async () => false,
|
|
||||||
getAppUserID: async () => "expo-go-preview",
|
|
||||||
close: () => {},
|
|
||||||
configureInUITestMode: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
LOG_LEVEL,
|
|
||||||
PRODUCT_CATEGORY,
|
|
||||||
PURCHASE_TYPE,
|
|
||||||
PURCHASES_ARE_COMPLETED_BY_TYPE,
|
|
||||||
REFUND_REQUEST_STATUS,
|
|
||||||
BILLING_FEATURE,
|
|
||||||
STOREKIT_VERSION,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Purchases;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { TextInput as RNTextInput, type TextInputProps } from 'react-native';
|
|
||||||
|
|
||||||
const TextInput = React.forwardRef<RNTextInput, TextInputProps>((props, ref) => {
|
|
||||||
return (
|
|
||||||
<RNTextInput
|
|
||||||
ref={ref}
|
|
||||||
placeholderTextColor={props.placeholderTextColor || 'black'}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
TextInput.displayName = 'TextInput';
|
|
||||||
|
|
||||||
export default TextInput;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export default () => null;
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import type { ImageProps } from 'expo-image';
|
|
||||||
import * as ExpoImage from 'expo-image';
|
|
||||||
import { Buffer } from 'buffer';
|
|
||||||
import React, { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
function buildGridPlaceholder(w: number, h: number): string {
|
|
||||||
const size = Math.max(w, h);
|
|
||||||
const svg = `
|
|
||||||
<svg width="${size}" height="${size}" viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="895" height="895" rx="19" fill="#E9E7E7"/>
|
|
||||||
<g stroke="#C0C0C0" stroke-width="1.00975">
|
|
||||||
<line x1="447.505" y1="-23" x2="447.505" y2="901"/>
|
|
||||||
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505"/>
|
|
||||||
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068"/>
|
|
||||||
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504"/>
|
|
||||||
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061"/>
|
|
||||||
<line x1="282.495" y1="907" x2="282.495" y2="-30"/>
|
|
||||||
<line x1="611.495" y1="907" x2="611.495" y2="-30"/>
|
|
||||||
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061"/>
|
|
||||||
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53"/>
|
|
||||||
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612"/>
|
|
||||||
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643"/>
|
|
||||||
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772"/>
|
|
||||||
<circle cx="447.5" cy="441.5" r="163.995"/>
|
|
||||||
<circle cx="447.911" cy="447.911" r="237.407"/>
|
|
||||||
<circle cx="448" cy="442" r="384.495"/>
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
const b64 = Buffer.from(svg).toString('base64');
|
|
||||||
return `data:image/svg+xml;base64,${b64}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Src = ImageProps['source'];
|
|
||||||
function computeSourceKey(src: Src): string {
|
|
||||||
if (Array.isArray(src)) return src.map(computeSourceKey).join('|');
|
|
||||||
if (typeof src === 'number') return String(src); // require('./img.png')
|
|
||||||
if (typeof src === 'string') return src; // remote on web
|
|
||||||
if (src && typeof src === 'object' && 'uri' in src) return src.uri ?? '';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const WrappedImage = forwardRef<ExpoImage.Image, ImageProps>(function WrappedImage(props, ref) {
|
|
||||||
const [fallbackSource, setFallbackSource] = useState<Src | null>(null);
|
|
||||||
const source = props.source;
|
|
||||||
const onError = props.onError;
|
|
||||||
const style = props.style;
|
|
||||||
const currentKey = computeSourceKey(props.source);
|
|
||||||
const prevKeyRef = useRef(currentKey);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevKeyRef.current !== currentKey) {
|
|
||||||
// parent really pointed to a different image: clear any old fallback
|
|
||||||
setFallbackSource(null);
|
|
||||||
prevKeyRef.current = currentKey;
|
|
||||||
}
|
|
||||||
}, [currentKey]);
|
|
||||||
const handleError: ImageProps['onError'] = useCallback(
|
|
||||||
(e: ExpoImage.ImageErrorEventData) => {
|
|
||||||
onError?.(e);
|
|
||||||
|
|
||||||
/* already swapped or dealing with a multi‑src array */
|
|
||||||
if (fallbackSource || Array.isArray(source)) return;
|
|
||||||
|
|
||||||
// prevent it from recursing
|
|
||||||
if (
|
|
||||||
source &&
|
|
||||||
typeof source === 'object' &&
|
|
||||||
'uri' in source &&
|
|
||||||
source?.uri?.startsWith('data:')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
/* try to infer a sensible grid size */
|
|
||||||
const finalStyle = Array.isArray(style) ? Object.assign({}, ...style) : style;
|
|
||||||
const width = finalStyle?.width ?? 128;
|
|
||||||
const height = finalStyle?.height ?? 128;
|
|
||||||
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
setFallbackSource({ uri: buildGridPlaceholder(width, height) });
|
|
||||||
} else {
|
|
||||||
setFallbackSource(require('../../src/__create/placeholder.svg'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[source, fallbackSource, onError, style]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExpoImage.Image {...props} source={fallbackSource ?? source} ref={ref} onError={handleError} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* expose static helpers so nothing breaks */
|
|
||||||
Object.assign(WrappedImage, ExpoImage);
|
|
||||||
|
|
||||||
/* re‑export everything that expo-image provides */
|
|
||||||
export * from 'expo-image';
|
|
||||||
export const Image = WrappedImage;
|
|
||||||
export default Image;
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React, { forwardRef, type ReactNode } from 'react';
|
|
||||||
import { View } from 'react-native';
|
|
||||||
|
|
||||||
import { SafeAreaView as NativeSafeAreaView } from 'react-native-safe-area-context/lib/commonjs';
|
|
||||||
export {
|
|
||||||
initialWindowMetrics,
|
|
||||||
SafeAreaFrameContext,
|
|
||||||
SafeAreaInsetsContext,
|
|
||||||
SafeAreaProvider,
|
|
||||||
useSafeAreaFrame,
|
|
||||||
} from 'react-native-safe-area-context/lib/commonjs';
|
|
||||||
|
|
||||||
type Edge = 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
type Edges = Edge[] | Record<Edge, 'off' | 'additive' | 'maximum'>;
|
|
||||||
|
|
||||||
interface SafeAreaViewProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
edges?: Edges;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SafeAreaView = forwardRef<View, SafeAreaViewProps>(
|
|
||||||
({ children, edges = ['top', 'right', 'bottom', 'left'] as Edges, ...rest }, forwardedRef) => {
|
|
||||||
const isTabletAndAbove = typeof window !== 'undefined' ? window.self !== window.top : true;
|
|
||||||
return (
|
|
||||||
<NativeSafeAreaView {...rest} edges={edges} ref={forwardedRef}>
|
|
||||||
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('top') || (!Array.isArray(edges) && (edges as Record<Edge, string>).top !== 'off')) && (
|
|
||||||
<View style={{ height: 64 }} />
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('bottom') || (!Array.isArray(edges) && (edges as Record<Edge, string>).bottom !== 'off')) && (
|
|
||||||
<View style={{ height: 34 }} />
|
|
||||||
)}
|
|
||||||
</NativeSafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
export default SafeAreaView;
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Modal,
|
|
||||||
StyleSheet,
|
|
||||||
Animated,
|
|
||||||
TouchableOpacity,
|
|
||||||
TextInput,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
type AlertButton = {
|
|
||||||
text: string;
|
|
||||||
onPress?: (value?: string | { login: string; password: string }) => void;
|
|
||||||
style: 'cancel' | 'destructive' | 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
type AlertOptions = {
|
|
||||||
userInterfaceStyle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AlertType = 'default' | 'plain-text' | 'secure-text' | 'login-password';
|
|
||||||
|
|
||||||
let globalAlertData = {
|
|
||||||
visible: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
buttons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
|
|
||||||
userInterfaceStyle: 'light',
|
|
||||||
};
|
|
||||||
let setGlobalAlert: ((data: typeof globalAlertData) => void) | null = null;
|
|
||||||
|
|
||||||
let globalPromptData = {
|
|
||||||
visible: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
callbackOrButtons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
|
|
||||||
type: 'default',
|
|
||||||
defaultValue: '',
|
|
||||||
keyboardType: 'default',
|
|
||||||
userInterfaceStyle: 'light',
|
|
||||||
};
|
|
||||||
let setGlobalPrompt: ((data: typeof globalPromptData) => void) | null = null;
|
|
||||||
|
|
||||||
const processButtons = (
|
|
||||||
buttons?: AlertButton[],
|
|
||||||
includeCancel = false
|
|
||||||
): AlertButton[] => {
|
|
||||||
let processedButtons =
|
|
||||||
buttons && buttons.length > 0
|
|
||||||
? buttons.map((button) => ({ ...button, onPress: button.onPress || (() => {}) }))
|
|
||||||
: includeCancel
|
|
||||||
? [
|
|
||||||
{ text: 'Cancel', onPress: () => {}, style: 'cancel' as const },
|
|
||||||
{ text: 'OK', onPress: () => {}, style: 'default' as const },
|
|
||||||
]
|
|
||||||
: [{ text: 'OK', onPress: () => {}, style: 'default' as const }];
|
|
||||||
|
|
||||||
// cancel button should always be the last button unless there are two buttons
|
|
||||||
if (processedButtons.length === 2) {
|
|
||||||
const cancelIndex = processedButtons.findIndex(
|
|
||||||
(btn) => btn.style === 'cancel'
|
|
||||||
);
|
|
||||||
if (cancelIndex === 1) {
|
|
||||||
processedButtons = [processedButtons[1], processedButtons[0]];
|
|
||||||
}
|
|
||||||
} else if (processedButtons.length >= 3) {
|
|
||||||
const cancelIndex = processedButtons.findLastIndex(
|
|
||||||
(btn) => btn.style === 'cancel'
|
|
||||||
);
|
|
||||||
if (cancelIndex !== -1 && cancelIndex !== processedButtons.length - 1) {
|
|
||||||
const cancelButton = processedButtons[cancelIndex];
|
|
||||||
const otherButtons = processedButtons.filter(
|
|
||||||
(_, index) => index !== cancelIndex
|
|
||||||
);
|
|
||||||
processedButtons = [...otherButtons, cancelButton];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedButtons;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Alert = {
|
|
||||||
alert(
|
|
||||||
title: string,
|
|
||||||
message: string,
|
|
||||||
buttons?: AlertButton[],
|
|
||||||
userInterfaceStyle?: AlertOptions
|
|
||||||
) {
|
|
||||||
const processedButtons = processButtons(buttons);
|
|
||||||
|
|
||||||
globalAlertData = {
|
|
||||||
visible: true,
|
|
||||||
title: title,
|
|
||||||
message: message || '',
|
|
||||||
buttons: processedButtons.map((button) => ({
|
|
||||||
...button,
|
|
||||||
onPress: button.onPress || (() => {}),
|
|
||||||
})),
|
|
||||||
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
|
|
||||||
};
|
|
||||||
if (setGlobalAlert) {
|
|
||||||
setGlobalAlert({ ...globalAlertData });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
prompt(
|
|
||||||
title: string,
|
|
||||||
message?: string,
|
|
||||||
callbackOrButtons?: AlertButton[],
|
|
||||||
type?: AlertType,
|
|
||||||
defaultValue?: string,
|
|
||||||
keyboardType?: string,
|
|
||||||
userInterfaceStyle?: AlertOptions
|
|
||||||
) {
|
|
||||||
const processedButtons = processButtons(callbackOrButtons, true);
|
|
||||||
|
|
||||||
globalPromptData = {
|
|
||||||
visible: true,
|
|
||||||
title: title,
|
|
||||||
message: message || '',
|
|
||||||
callbackOrButtons: processedButtons.map((button) => ({
|
|
||||||
...button,
|
|
||||||
onPress: button.onPress || (() => {}),
|
|
||||||
})),
|
|
||||||
type: type || 'plain-text',
|
|
||||||
defaultValue: defaultValue || '',
|
|
||||||
keyboardType: keyboardType || 'default',
|
|
||||||
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
|
|
||||||
};
|
|
||||||
if (setGlobalPrompt) {
|
|
||||||
setGlobalPrompt({ ...globalPromptData });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AlertModal = () => {
|
|
||||||
const [alertData, setAlertData] = useState(globalAlertData);
|
|
||||||
const [promptData, setPromptData] = useState(globalPromptData);
|
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const [currentModalData, setCurrentModalData] = useState<{
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
buttons: {
|
|
||||||
text: string;
|
|
||||||
onPress: (value?: string | { login: string; password: string }) => void;
|
|
||||||
style: string;
|
|
||||||
}[];
|
|
||||||
userInterfaceStyle: string;
|
|
||||||
isPrompt: boolean;
|
|
||||||
type?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
keyboardType?: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [loginValue, setLoginValue] = useState('');
|
|
||||||
const scaleAnim = useRef(new Animated.Value(1.25)).current;
|
|
||||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const showModal = () => {
|
|
||||||
setModalVisible(true);
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(opacityAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 250,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(scaleAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 250,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (promptData.visible) {
|
|
||||||
setCurrentModalData({
|
|
||||||
...promptData,
|
|
||||||
isPrompt: true,
|
|
||||||
buttons: promptData.callbackOrButtons,
|
|
||||||
});
|
|
||||||
showModal();
|
|
||||||
} else if (alertData.visible) {
|
|
||||||
setCurrentModalData({
|
|
||||||
...alertData,
|
|
||||||
buttons: alertData.buttons,
|
|
||||||
isPrompt: false,
|
|
||||||
});
|
|
||||||
showModal();
|
|
||||||
} else {
|
|
||||||
setCurrentModalData(null);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
promptData.visible,
|
|
||||||
alertData.visible,
|
|
||||||
promptData,
|
|
||||||
alertData,
|
|
||||||
opacityAnim,
|
|
||||||
scaleAnim,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const modalData = currentModalData || {
|
|
||||||
...alertData,
|
|
||||||
isPrompt: false,
|
|
||||||
buttons: alertData.buttons,
|
|
||||||
type: 'default',
|
|
||||||
defaultValue: '',
|
|
||||||
keyboardType: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setGlobalAlert = setAlertData;
|
|
||||||
setGlobalPrompt = setPromptData;
|
|
||||||
return () => {
|
|
||||||
setGlobalAlert = null;
|
|
||||||
setGlobalPrompt = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
Animated.timing(opacityAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 250,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start(() => {
|
|
||||||
setModalVisible(false);
|
|
||||||
scaleAnim.setValue(1.25);
|
|
||||||
setInputValue('');
|
|
||||||
setLoginValue('');
|
|
||||||
globalAlertData = {
|
|
||||||
visible: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
buttons: [],
|
|
||||||
userInterfaceStyle: 'light',
|
|
||||||
};
|
|
||||||
globalPromptData = {
|
|
||||||
visible: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
callbackOrButtons: [],
|
|
||||||
type: 'default',
|
|
||||||
defaultValue: '',
|
|
||||||
keyboardType: 'default',
|
|
||||||
userInterfaceStyle: 'light',
|
|
||||||
};
|
|
||||||
setAlertData(globalAlertData);
|
|
||||||
setPromptData(globalPromptData);
|
|
||||||
setCurrentModalData(null);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = styling(modalData.userInterfaceStyle);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={modalVisible} transparent animationType="none">
|
|
||||||
<Animated.View style={[styles.container, { opacity: opacityAnim }]}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.content,
|
|
||||||
{ transform: [{ scale: scaleAnim }] },
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.65)',
|
|
||||||
}
|
|
||||||
: { backgroundColor: 'rgba(255, 255, 255, 0.75)' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.title,
|
|
||||||
modalData.userInterfaceStyle === 'dark' && {
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{modalData.title}
|
|
||||||
</Text>
|
|
||||||
{modalData.message ? (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.message,
|
|
||||||
modalData.userInterfaceStyle === 'dark' && {
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{modalData.message}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{modalData?.isPrompt && modalData.type !== 'default' ? (
|
|
||||||
<View>
|
|
||||||
{modalData.type === 'login-password' ? (
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.textInput,
|
|
||||||
styles.textInputTop,
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
color: 'white',
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
|
||||||
color: 'black',
|
|
||||||
borderColor: 'rgba(0, 0, 0, 0.2)',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={loginValue}
|
|
||||||
onChangeText={setLoginValue}
|
|
||||||
placeholder="Login"
|
|
||||||
placeholderTextColor={
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.5)'
|
|
||||||
: 'rgba(0,0,0,0.5)'
|
|
||||||
}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.textInput,
|
|
||||||
modalData.type === 'login-password' &&
|
|
||||||
styles.textInputBottom,
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
color: 'white',
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
|
||||||
color: 'black',
|
|
||||||
borderColor: 'rgba(0, 0, 0, 0.2)',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={inputValue}
|
|
||||||
onChangeText={setInputValue}
|
|
||||||
placeholder={(() => {
|
|
||||||
switch (modalData.type) {
|
|
||||||
case 'plain-text':
|
|
||||||
return '';
|
|
||||||
case 'secure-text':
|
|
||||||
case 'login-password':
|
|
||||||
return 'Password';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
placeholderTextColor={
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.5)'
|
|
||||||
: 'rgba(0,0,0,0.5)'
|
|
||||||
}
|
|
||||||
secureTextEntry={
|
|
||||||
modalData.type === 'secure-text' ||
|
|
||||||
modalData.type === 'login-password'
|
|
||||||
}
|
|
||||||
keyboardType={
|
|
||||||
modalData.keyboardType === 'numeric' ? 'numeric' : 'default'
|
|
||||||
}
|
|
||||||
autoFocus={modalData.type !== 'login-password'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
modalData.buttons.length >= 3
|
|
||||||
? styles.buttonColumnContainer
|
|
||||||
: styles.buttonRowContainer,
|
|
||||||
modalData.buttons.length <= 2 && styles.buttonTopBorder,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{modalData.buttons.map((button, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={`${button.text}-${index}`}
|
|
||||||
onPress={() => {
|
|
||||||
if (modalData?.isPrompt) {
|
|
||||||
let valueToPass:
|
|
||||||
| string
|
|
||||||
| { login: string; password: string } = inputValue;
|
|
||||||
if (modalData.type === 'login-password') {
|
|
||||||
valueToPass = {
|
|
||||||
login: loginValue,
|
|
||||||
password: inputValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
button.onPress(valueToPass);
|
|
||||||
} else {
|
|
||||||
button.onPress();
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
}}
|
|
||||||
style={[
|
|
||||||
styles.button,
|
|
||||||
modalData.buttons.length >= 3 && styles.buttonTopBorder,
|
|
||||||
modalData.buttons.length === 2 && { width: '50%' },
|
|
||||||
modalData.buttons.length <= 1 && { width: '100%' },
|
|
||||||
index === 0 &&
|
|
||||||
modalData.buttons.length === 2 && {
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderColor:
|
|
||||||
modalData.userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.2)'
|
|
||||||
: 'lightgray',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.buttonText,
|
|
||||||
button.style === 'cancel' && { fontWeight: '600' },
|
|
||||||
button.style === 'destructive' && { color: 'red' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{button.text}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styling = (userInterfaceStyle: string) =>
|
|
||||||
StyleSheet.create<Record<string, any>>({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
backdropFilter: 'blur(20px)' as any,
|
|
||||||
borderRadius: 12,
|
|
||||||
width: 244,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
paddingVertical: 20,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
color: '#007AFF',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
borderWidth: 0.5,
|
|
||||||
borderColor: 'lightgray',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: -8,
|
|
||||||
marginHorizontal: 12,
|
|
||||||
fontSize: 12,
|
|
||||||
outlineStyle: 'none' as any,
|
|
||||||
},
|
|
||||||
textInputTop: {
|
|
||||||
borderTopLeftRadius: 8,
|
|
||||||
borderTopRightRadius: 8,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
},
|
|
||||||
textInputBottom: {
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomLeftRadius: 8,
|
|
||||||
borderBottomRightRadius: 8,
|
|
||||||
marginTop: 0,
|
|
||||||
},
|
|
||||||
buttonTopBorder: {
|
|
||||||
borderTopWidth: 0.5,
|
|
||||||
borderTopColor:
|
|
||||||
userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.2)'
|
|
||||||
: 'lightgray',
|
|
||||||
},
|
|
||||||
buttonRowContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderTopWidth: 0.5,
|
|
||||||
borderTopColor:
|
|
||||||
userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.2)'
|
|
||||||
: 'lightgray',
|
|
||||||
},
|
|
||||||
buttonColumnContainer: {
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
buttonRightBorder: {
|
|
||||||
borderRightWidth: 0.5,
|
|
||||||
borderRightColor:
|
|
||||||
userInterfaceStyle === 'dark'
|
|
||||||
? 'rgba(255,255,255,0.2)'
|
|
||||||
: 'lightgray',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Alert;
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { View, Text, StyleSheet, type ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
export enum CameraType {
|
|
||||||
front = 'front',
|
|
||||||
back = 'back',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum FlashMode {
|
|
||||||
off = 'off',
|
|
||||||
on = 'on',
|
|
||||||
auto = 'auto',
|
|
||||||
torch = 'torch',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CameraMode {
|
|
||||||
picture = 'picture',
|
|
||||||
video = 'video',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CameraViewProps {
|
|
||||||
style?: ViewStyle;
|
|
||||||
facing?: 'front' | 'back';
|
|
||||||
flash?: 'off' | 'on' | 'auto';
|
|
||||||
mode?: 'picture' | 'video';
|
|
||||||
zoom?: number;
|
|
||||||
enableTorch?: boolean;
|
|
||||||
onCameraReady?: () => void;
|
|
||||||
onMountError?: (event: { message: string }) => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CameraViewRef {
|
|
||||||
takePictureAsync: (options?: {
|
|
||||||
quality?: number;
|
|
||||||
base64?: boolean;
|
|
||||||
}) => Promise<{ uri: string; width: number; height: number; base64?: string }>;
|
|
||||||
recordAsync: (options?: {
|
|
||||||
maxDuration?: number;
|
|
||||||
}) => Promise<{ uri: string }>;
|
|
||||||
stopRecording: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CameraView = forwardRef<CameraViewRef, CameraViewProps>(
|
|
||||||
function CameraView(
|
|
||||||
{ style, facing = 'back', onCameraReady, onMountError, children },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
|
||||||
const [hasCamera, setHasCamera] = useState(true);
|
|
||||||
|
|
||||||
const startCamera = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: {
|
|
||||||
facingMode: facing === 'front' ? 'user' : 'environment',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
streamRef.current = stream;
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.srcObject = stream;
|
|
||||||
await videoRef.current.play();
|
|
||||||
}
|
|
||||||
onCameraReady?.();
|
|
||||||
} catch (err) {
|
|
||||||
setHasCamera(false);
|
|
||||||
onMountError?.({
|
|
||||||
message:
|
|
||||||
err instanceof Error ? err.message : 'Camera not available',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [facing, onCameraReady, onMountError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void startCamera();
|
|
||||||
return () => {
|
|
||||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
||||||
streamRef.current = null;
|
|
||||||
};
|
|
||||||
}, [startCamera]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
takePictureAsync: async (options) => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video || !streamRef.current) {
|
|
||||||
throw new Error('Camera is not ready');
|
|
||||||
}
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
ctx.drawImage(video, 0, 0);
|
|
||||||
const quality = options?.quality ?? 0.85;
|
|
||||||
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
|
||||||
const result: {
|
|
||||||
uri: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
base64?: string;
|
|
||||||
} = {
|
|
||||||
uri: dataUrl,
|
|
||||||
width: canvas.width,
|
|
||||||
height: canvas.height,
|
|
||||||
};
|
|
||||||
if (options?.base64) {
|
|
||||||
result.base64 = dataUrl.split(',')[1];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
recordAsync: async () => {
|
|
||||||
throw new Error('Video recording is not supported in web preview');
|
|
||||||
},
|
|
||||||
stopRecording: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!hasCamera) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<View style={styles.placeholder}>
|
|
||||||
<Text style={styles.placeholderText}>Camera</Text>
|
|
||||||
<Text style={styles.placeholderSubtext}>
|
|
||||||
Not available in this browser
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const grantedPermission = {
|
|
||||||
status: 'granted' as const,
|
|
||||||
granted: true,
|
|
||||||
canAskAgain: true,
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const deniedPermission = {
|
|
||||||
status: 'denied' as const,
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: true,
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useCameraPermissions(): [
|
|
||||||
{ status: string; granted: boolean; canAskAgain: boolean } | null,
|
|
||||||
() => Promise<{ status: string; granted: boolean; canAskAgain: boolean }>,
|
|
||||||
] {
|
|
||||||
const [permission, setPermission] = useState<{
|
|
||||||
status: string;
|
|
||||||
granted: boolean;
|
|
||||||
canAskAgain: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const requestPermission = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
|
||||||
setPermission(grantedPermission);
|
|
||||||
return grantedPermission;
|
|
||||||
} catch {
|
|
||||||
setPermission(deniedPermission);
|
|
||||||
return deniedPermission;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [permission, requestPermission];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestCameraPermissionsAsync() {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
|
||||||
return grantedPermission;
|
|
||||||
} catch {
|
|
||||||
return deniedPermission;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCameraPermissionsAsync() {
|
|
||||||
try {
|
|
||||||
const result = await navigator.permissions.query({
|
|
||||||
name: 'camera' as PermissionName,
|
|
||||||
});
|
|
||||||
return result.state === 'granted' ? grantedPermission : deniedPermission;
|
|
||||||
} catch {
|
|
||||||
return deniedPermission;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: '#000',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
},
|
|
||||||
placeholderText: {
|
|
||||||
color: '#999',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
placeholderSubtext: {
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
export async function getStringAsync(): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await navigator.clipboard.readText();
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setStringAsync(text: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasStringAsync(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
return text.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImageAsync() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setImageAsync() {}
|
|
||||||
|
|
||||||
export async function hasImageAsync(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addClipboardListener(
|
|
||||||
_listener: (event: { contentTypes: string[] }) => void
|
|
||||||
) {
|
|
||||||
return { remove: () => {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeClipboardListener(subscription: { remove: () => void }) {
|
|
||||||
subscription.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPlatformSupported(): boolean {
|
|
||||||
return typeof navigator !== 'undefined' && !!navigator.clipboard;
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import type { ExistingContact, ContactQuery } from 'expo-contacts';
|
|
||||||
import { Fields, SortTypes } from 'expo-contacts/src/Contacts';
|
|
||||||
import Alert from './alerts.web';
|
|
||||||
import * as Notifications from 'expo-contacts';
|
|
||||||
const { PermissionStatus } = Notifications;
|
|
||||||
|
|
||||||
export { PermissionStatus, Fields, SortTypes };
|
|
||||||
|
|
||||||
const fakeContacts: ExistingContact[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
contactType: 'person',
|
|
||||||
name: 'John Doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe',
|
|
||||||
phoneNumbers: [
|
|
||||||
{ number: '+1 (555) 123-4567', isPrimary: true, label: 'mobile' },
|
|
||||||
{ number: '+1 (555) 987-6543', isPrimary: false, label: 'home' },
|
|
||||||
],
|
|
||||||
emails: [
|
|
||||||
{ email: 'john.doe@example.com', isPrimary: true, label: 'work' },
|
|
||||||
{ email: 'john.personal@gmail.com', isPrimary: false, label: 'personal' },
|
|
||||||
],
|
|
||||||
addresses: [
|
|
||||||
{
|
|
||||||
street: '123 Main St',
|
|
||||||
city: 'New York',
|
|
||||||
region: 'NY',
|
|
||||||
postalCode: '10001',
|
|
||||||
country: 'USA',
|
|
||||||
label: 'home',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
birthday: { day: 15, month: 5, year: 1990 },
|
|
||||||
note: 'Met at conference',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
contactType: 'person',
|
|
||||||
name: 'Jane Smith',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Smith',
|
|
||||||
phoneNumbers: [{ number: '+1 (555) 234-5678', isPrimary: true, label: 'mobile' }],
|
|
||||||
emails: [{ email: 'jane.smith@company.com', isPrimary: true, label: 'work' }],
|
|
||||||
addresses: [
|
|
||||||
{
|
|
||||||
street: '456 Oak Ave',
|
|
||||||
city: 'Los Angeles',
|
|
||||||
region: 'CA',
|
|
||||||
postalCode: '90210',
|
|
||||||
country: 'USA',
|
|
||||||
label: 'home',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
birthday: { day: 3, month: 12, year: 1985 },
|
|
||||||
note: 'College friend',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
contactType: 'person',
|
|
||||||
name: 'Bob Johnson',
|
|
||||||
firstName: 'Bob',
|
|
||||||
lastName: 'Johnson',
|
|
||||||
phoneNumbers: [{ number: '+1 (555) 345-6789', isPrimary: true, label: 'mobile' }],
|
|
||||||
emails: [{ email: 'bob.johnson@email.com', isPrimary: true, label: 'personal' }],
|
|
||||||
addresses: [],
|
|
||||||
birthday: { day: 22, month: 8, year: 1992 },
|
|
||||||
note: 'Neighbor',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
contactType: 'person',
|
|
||||||
name: 'Alice Williams',
|
|
||||||
firstName: 'Alice',
|
|
||||||
lastName: 'Williams',
|
|
||||||
phoneNumbers: [
|
|
||||||
{ number: '+1 (555) 456-7890', isPrimary: true, label: 'mobile' },
|
|
||||||
{ number: '+1 (555) 111-2222', isPrimary: false, label: 'work' },
|
|
||||||
],
|
|
||||||
emails: [{ email: 'alice.williams@startup.com', isPrimary: true, label: 'work' }],
|
|
||||||
addresses: [
|
|
||||||
{
|
|
||||||
street: '789 Pine St',
|
|
||||||
city: 'San Francisco',
|
|
||||||
region: 'CA',
|
|
||||||
postalCode: '94102',
|
|
||||||
country: 'USA',
|
|
||||||
label: 'work',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
birthday: { day: 10, month: 3, year: 1988 },
|
|
||||||
note: 'Business partner',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
contactType: 'person',
|
|
||||||
name: 'Charlie Brown',
|
|
||||||
firstName: 'Charlie',
|
|
||||||
lastName: 'Brown',
|
|
||||||
phoneNumbers: [{ number: '+1 (555) 567-8901', isPrimary: true, label: 'mobile' }],
|
|
||||||
emails: [{ email: 'charlie.brown@gmail.com', isPrimary: true, label: 'personal' }],
|
|
||||||
addresses: [],
|
|
||||||
birthday: { day: 18, month: 11, year: 1995 },
|
|
||||||
note: 'Gym buddy',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let permissionStatus = {
|
|
||||||
status: PermissionStatus.UNDETERMINED,
|
|
||||||
expires: 'never',
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// since we polyfill fake contacts, we always return true
|
|
||||||
export const isAvailableAsync = async () => {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestPermissionsAsync = async () => {
|
|
||||||
if (permissionStatus.status === PermissionStatus.GRANTED) {
|
|
||||||
return permissionStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
Alert.alert(
|
|
||||||
'"Expo Go" Would Like to Access Your Contacts',
|
|
||||||
'Allow Expo projects to access your contacts',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Don't Allow",
|
|
||||||
onPress: () => {
|
|
||||||
permissionStatus = {
|
|
||||||
status: PermissionStatus.DENIED,
|
|
||||||
expires: 'never',
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: true,
|
|
||||||
};
|
|
||||||
resolve(permissionStatus);
|
|
||||||
},
|
|
||||||
style: 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Continue',
|
|
||||||
onPress: () => {
|
|
||||||
permissionStatus = {
|
|
||||||
status: PermissionStatus.GRANTED,
|
|
||||||
expires: 'never',
|
|
||||||
granted: true,
|
|
||||||
canAskAgain: false,
|
|
||||||
};
|
|
||||||
resolve(permissionStatus);
|
|
||||||
},
|
|
||||||
style: 'default',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPermissionsAsync = async () => {
|
|
||||||
return permissionStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getContactsAsync = async (options: ContactQuery = {}) => {
|
|
||||||
const { sort = SortTypes.FirstName, pageSize, pageOffset } = options;
|
|
||||||
|
|
||||||
let contacts = [...fakeContacts];
|
|
||||||
|
|
||||||
if (sort === SortTypes.FirstName) {
|
|
||||||
contacts.sort((a, b) => (a.firstName || '').localeCompare(b.firstName || ''));
|
|
||||||
} else if (sort === SortTypes.LastName) {
|
|
||||||
contacts.sort((a, b) => (a.lastName || '').localeCompare(b.lastName || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageSize && pageOffset !== undefined) {
|
|
||||||
const startIndex = pageOffset * pageSize;
|
|
||||||
contacts = contacts.slice(startIndex, startIndex + pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: contacts,
|
|
||||||
hasNextPage: false,
|
|
||||||
hasPreviousPage: false,
|
|
||||||
total: fakeContacts.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getContactByIdAsync = async (id: string, _options: ContactQuery = {}) => {
|
|
||||||
const contact = fakeContacts.find((c) => c.id === id);
|
|
||||||
if (!contact) {
|
|
||||||
throw new Error(`Contact with id ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return contact;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addContactAsync = async (contact: ExistingContact) => {
|
|
||||||
const newContact: ExistingContact = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
contactType: contact.contactType || 'person',
|
|
||||||
name: contact.name || '',
|
|
||||||
firstName: contact.firstName || '',
|
|
||||||
lastName: contact.lastName || '',
|
|
||||||
phoneNumbers: contact.phoneNumbers || [],
|
|
||||||
emails: contact.emails || [],
|
|
||||||
addresses: contact.addresses || [],
|
|
||||||
birthday: contact.birthday,
|
|
||||||
note: contact.note || '',
|
|
||||||
middleName: contact.middleName,
|
|
||||||
maidenName: contact.maidenName,
|
|
||||||
namePrefix: contact.namePrefix,
|
|
||||||
nameSuffix: contact.nameSuffix,
|
|
||||||
nickname: contact.nickname,
|
|
||||||
phoneticFirstName: contact.phoneticFirstName,
|
|
||||||
phoneticMiddleName: contact.phoneticMiddleName,
|
|
||||||
phoneticLastName: contact.phoneticLastName,
|
|
||||||
company: contact.company,
|
|
||||||
jobTitle: contact.jobTitle,
|
|
||||||
department: contact.department,
|
|
||||||
imageAvailable: contact.imageAvailable,
|
|
||||||
image: contact.image,
|
|
||||||
rawImage: contact.rawImage,
|
|
||||||
dates: contact.dates,
|
|
||||||
relationships: contact.relationships,
|
|
||||||
instantMessageAddresses: contact.instantMessageAddresses,
|
|
||||||
urlAddresses: contact.urlAddresses,
|
|
||||||
nonGregorianBirthday: contact.nonGregorianBirthday,
|
|
||||||
socialProfiles: contact.socialProfiles,
|
|
||||||
isFavorite: contact.isFavorite,
|
|
||||||
};
|
|
||||||
|
|
||||||
fakeContacts.push(newContact);
|
|
||||||
Alert.alert('Success', 'Contact added successfully!');
|
|
||||||
return newContact.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateContactAsync = async (contact: ExistingContact) => {
|
|
||||||
const index = fakeContacts.findIndex((c) => c.id === contact.id);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Contact with id ${contact.id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
fakeContacts[index] = { ...fakeContacts[index], ...contact };
|
|
||||||
return contact.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeContactAsync = async (contactId: string) => {
|
|
||||||
const index = fakeContacts.findIndex((c) => c.id === contactId);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Contact with id ${contactId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
fakeContacts.splice(index, 1);
|
|
||||||
setTimeout(() => {
|
|
||||||
Alert.alert('Success', 'Contact deleted successfully!');
|
|
||||||
}, 500);
|
|
||||||
return contactId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const _createNoOpAsync = async () => {
|
|
||||||
Alert.alert('Not supported in the builder', 'Please use the Expo Go app to test this feature');
|
|
||||||
return { type: 'custom', data: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const presentContactPickerAsync = async () => {
|
|
||||||
return _createNoOpAsync();
|
|
||||||
};
|
|
||||||
export const getGroupsAsync = async () => {
|
|
||||||
return _createNoOpAsync();
|
|
||||||
};
|
|
||||||
export const createGroupAsync = async () => {
|
|
||||||
return _createNoOpAsync();
|
|
||||||
};
|
|
||||||
export const removeGroupAsync = async () => {
|
|
||||||
return _createNoOpAsync();
|
|
||||||
};
|
|
||||||
export const updateGroupNameAsync = async () => {
|
|
||||||
return _createNoOpAsync();
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Fields,
|
|
||||||
SortTypes,
|
|
||||||
PermissionStatus,
|
|
||||||
isAvailableAsync,
|
|
||||||
requestPermissionsAsync,
|
|
||||||
getPermissionsAsync,
|
|
||||||
getContactsAsync,
|
|
||||||
getContactByIdAsync,
|
|
||||||
addContactAsync,
|
|
||||||
updateContactAsync,
|
|
||||||
removeContactAsync,
|
|
||||||
presentContactPickerAsync,
|
|
||||||
getGroupsAsync,
|
|
||||||
createGroupAsync,
|
|
||||||
removeGroupAsync,
|
|
||||||
updateGroupNameAsync,
|
|
||||||
};
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
interface DocumentPickerAsset {
|
|
||||||
name: string;
|
|
||||||
size: number | null;
|
|
||||||
uri: string;
|
|
||||||
mimeType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentPickerResult {
|
|
||||||
canceled: boolean;
|
|
||||||
assets: DocumentPickerAsset[];
|
|
||||||
output: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentPickerOptions {
|
|
||||||
type?: string | string[];
|
|
||||||
copyToCacheDirectory?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDocumentAsync(
|
|
||||||
options?: DocumentPickerOptions
|
|
||||||
): Promise<DocumentPickerResult> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.multiple = options?.multiple ?? false;
|
|
||||||
|
|
||||||
if (options?.type) {
|
|
||||||
const types = Array.isArray(options.type)
|
|
||||||
? options.type
|
|
||||||
: [options.type];
|
|
||||||
const filtered = types.filter((t) => t !== '*/*');
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
input.accept = filtered.join(',');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.style.display = 'none';
|
|
||||||
document.body.appendChild(input);
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
const cleanup = () => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
document.body.removeChild(input);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
const files = input.files;
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: true, assets: [], output: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = Array.from(files).map(
|
|
||||||
(file) =>
|
|
||||||
new Promise<DocumentPickerAsset>((resolveAsset) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
resolveAsset({
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
uri: reader.result as string,
|
|
||||||
mimeType: file.type || null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
void Promise.all(promises).then((assets) => {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: false, assets, output: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener(
|
|
||||||
'focus',
|
|
||||||
() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!resolved) {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: true, assets: [], output: null });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
input.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export * from 'expo-font';
|
|
||||||
export { useFonts } from 'expo-font';
|
|
||||||
|
|
||||||
export async function renderToImageAsync(): Promise<{
|
|
||||||
uri: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}> {
|
|
||||||
return { uri: '', width: 0, height: 0 };
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Text, View, type ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
// Stub for react-native-google-mobile-ads on web.
|
|
||||||
// Ads are native-only; these render visual placeholders so users can preview
|
|
||||||
// their layouts in Expo Go without the native module.
|
|
||||||
|
|
||||||
export const BannerAdSize = {
|
|
||||||
BANNER: "BANNER",
|
|
||||||
FULL_BANNER: "FULL_BANNER",
|
|
||||||
LARGE_BANNER: "LARGE_BANNER",
|
|
||||||
LEADERBOARD: "LEADERBOARD",
|
|
||||||
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
|
|
||||||
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
|
|
||||||
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
|
|
||||||
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
|
|
||||||
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdEventType = {
|
|
||||||
LOADED: "loaded",
|
|
||||||
ERROR: "error",
|
|
||||||
OPENED: "opened",
|
|
||||||
CLICKED: "clicked",
|
|
||||||
CLOSED: "closed",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedAdEventType = {
|
|
||||||
LOADED: "loaded",
|
|
||||||
EARNED_REWARD: "earned_reward",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdsConsentStatus = {
|
|
||||||
UNKNOWN: 0,
|
|
||||||
REQUIRED: 1,
|
|
||||||
NOT_REQUIRED: 2,
|
|
||||||
OBTAINED: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdsConsentDebugGeography = {
|
|
||||||
DISABLED: 0,
|
|
||||||
EEA: 1,
|
|
||||||
NOT_EEA: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TestIds = {
|
|
||||||
BANNER: "ca-app-pub-3940256099942544/6300978111",
|
|
||||||
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
|
|
||||||
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
|
||||||
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
|
||||||
REWARDED: "ca-app-pub-3940256099942544/5224354917",
|
|
||||||
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
|
|
||||||
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
|
|
||||||
NATIVE: "ca-app-pub-3940256099942544/2247696110",
|
|
||||||
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLACEHOLDER_BG = "#f5f5f5";
|
|
||||||
const PLACEHOLDER_BORDER = "#e0e0e0";
|
|
||||||
const PLACEHOLDER_TEXT = "#999999";
|
|
||||||
const AD_LABEL_BG = "#fbbc04";
|
|
||||||
const AD_LABEL_TEXT = "#1a1a1a";
|
|
||||||
|
|
||||||
const AdLabel = () => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: AD_LABEL_BG,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: AD_LABEL_TEXT,
|
|
||||||
lineHeight: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ad
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getBannerStyle = (size: string | undefined): ViewStyle => {
|
|
||||||
switch (size) {
|
|
||||||
case "FULL_BANNER":
|
|
||||||
return { width: 468, height: 60 };
|
|
||||||
case "LARGE_BANNER":
|
|
||||||
return { width: 320, height: 100 };
|
|
||||||
case "LEADERBOARD":
|
|
||||||
return { width: 728, height: 90 };
|
|
||||||
case "MEDIUM_RECTANGLE":
|
|
||||||
return { width: 300, height: 250 };
|
|
||||||
case "WIDE_SKYSCRAPER":
|
|
||||||
return { width: 160, height: 600 };
|
|
||||||
case "ADAPTIVE_BANNER":
|
|
||||||
case "ANCHORED_ADAPTIVE_BANNER":
|
|
||||||
return { width: "100%", height: 50 };
|
|
||||||
case "INLINE_ADAPTIVE_BANNER":
|
|
||||||
return { width: "100%", height: 100 };
|
|
||||||
default:
|
|
||||||
return { width: 320, height: 50 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type BannerAdProps = {
|
|
||||||
size?: string;
|
|
||||||
unitId?: string;
|
|
||||||
onAdLoaded?: () => void;
|
|
||||||
onAdFailedToLoad?: (error: unknown) => void;
|
|
||||||
onAdOpened?: () => void;
|
|
||||||
onAdClosed?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BannerPlaceholder = ({
|
|
||||||
size,
|
|
||||||
label,
|
|
||||||
}: { size?: string; label: string }) => {
|
|
||||||
const dims = getBannerStyle(size);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
...dims,
|
|
||||||
backgroundColor: PLACEHOLDER_BG,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdLabel />
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BannerAd = ({ size }: BannerAdProps) => (
|
|
||||||
<BannerPlaceholder size={size} label="Banner Ad" />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const GAMBannerAd = ({ size }: BannerAdProps) => (
|
|
||||||
<BannerPlaceholder size={size} label="Ad Manager Banner" />
|
|
||||||
);
|
|
||||||
|
|
||||||
type NativeAdViewProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
nativeAd?: unknown;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultNativeAdContent = () => (
|
|
||||||
<View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
marginRight: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 12,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
width: "70%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "40%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 140,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 10,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
|
||||||
Native Ad Media
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: "#ececec",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "80%",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
backgroundColor: "#1a73e8",
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
|
|
||||||
Install
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
backgroundColor: PLACEHOLDER_BG,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
style as ViewStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
|
|
||||||
<AdLabel />
|
|
||||||
</View>
|
|
||||||
{children ?? <DefaultNativeAdContent />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAsset = ({
|
|
||||||
children,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
assetType?: string;
|
|
||||||
style?: ViewStyle | ViewStyle[];
|
|
||||||
}) => <View style={style as ViewStyle}>{children}</View>;
|
|
||||||
|
|
||||||
export const NativeMediaView = ({
|
|
||||||
style,
|
|
||||||
}: { style?: ViewStyle | ViewStyle[] }) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: 180,
|
|
||||||
backgroundColor: PLACEHOLDER_BORDER,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
style as ViewStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
|
||||||
Ad Media (native only)
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NativeAd = {
|
|
||||||
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
|
|
||||||
headline: "Sample Ad Headline",
|
|
||||||
body: "Native ads only render on a real device.",
|
|
||||||
advertiser: "Sample Advertiser",
|
|
||||||
callToAction: "Install",
|
|
||||||
icon: null,
|
|
||||||
images: [],
|
|
||||||
starRating: null,
|
|
||||||
store: null,
|
|
||||||
price: null,
|
|
||||||
addAdEventListener: () => () => {},
|
|
||||||
removeAllListeners: () => {},
|
|
||||||
destroy: () => {},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFullScreenAdStub = () => ({
|
|
||||||
loaded: false,
|
|
||||||
load: () => {},
|
|
||||||
show: () => Promise.resolve(),
|
|
||||||
addAdEventListener: () => () => {},
|
|
||||||
addAdEventsListener: () => () => {},
|
|
||||||
removeAllListeners: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const InterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RewardedInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppOpenAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMRewardedAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GAMRewardedInterstitialAd = {
|
|
||||||
createForAdRequest: () => createFullScreenAdStub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseHookResult = {
|
|
||||||
isLoaded: false,
|
|
||||||
isOpened: false,
|
|
||||||
isClicked: false,
|
|
||||||
isClosed: false,
|
|
||||||
error: null as unknown,
|
|
||||||
load: () => {},
|
|
||||||
show: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useInterstitialAd = () => ({ ...baseHookResult });
|
|
||||||
export const useAppOpenAd = () => ({ ...baseHookResult });
|
|
||||||
export const useRewardedAd = () => ({
|
|
||||||
...baseHookResult,
|
|
||||||
isEarnedReward: false,
|
|
||||||
reward: null,
|
|
||||||
});
|
|
||||||
export const useRewardedInterstitialAd = () => ({
|
|
||||||
...baseHookResult,
|
|
||||||
isEarnedReward: false,
|
|
||||||
reward: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AdsConsent = {
|
|
||||||
requestInfoUpdate: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
isConsentFormAvailable: false,
|
|
||||||
}),
|
|
||||||
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
|
|
||||||
loadAndShowConsentFormIfRequired: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
}),
|
|
||||||
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
|
|
||||||
reset: () => {},
|
|
||||||
getConsentInfo: async () => ({
|
|
||||||
status: AdsConsentStatus.NOT_REQUIRED,
|
|
||||||
canRequestAds: true,
|
|
||||||
isConsentFormAvailable: false,
|
|
||||||
privacyOptionsRequirementStatus: "NOT_REQUIRED",
|
|
||||||
}),
|
|
||||||
getUserChoices: async () => ({}),
|
|
||||||
getTCString: async () => "",
|
|
||||||
getGdprApplies: async () => false,
|
|
||||||
getPurposeConsents: async () => "",
|
|
||||||
getPurposeLegitimateInterests: async () => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mobileAdsInstance = {
|
|
||||||
initialize: async () => [],
|
|
||||||
setRequestConfiguration: async () => {},
|
|
||||||
openAdInspector: async () => {},
|
|
||||||
openDebugMenu: () => {},
|
|
||||||
setAppMuted: () => {},
|
|
||||||
setAppVolume: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mobileAds = () => mobileAdsInstance;
|
|
||||||
|
|
||||||
const defaultExport = Object.assign(mobileAds, {
|
|
||||||
BannerAd,
|
|
||||||
GAMBannerAd,
|
|
||||||
BannerAdSize,
|
|
||||||
InterstitialAd,
|
|
||||||
RewardedAd,
|
|
||||||
RewardedInterstitialAd,
|
|
||||||
AppOpenAd,
|
|
||||||
GAMInterstitialAd,
|
|
||||||
GAMRewardedAd,
|
|
||||||
GAMRewardedInterstitialAd,
|
|
||||||
NativeAd,
|
|
||||||
NativeAdView,
|
|
||||||
NativeAsset,
|
|
||||||
NativeMediaView,
|
|
||||||
AdEventType,
|
|
||||||
RewardedAdEventType,
|
|
||||||
AdsConsent,
|
|
||||||
AdsConsentStatus,
|
|
||||||
AdsConsentDebugGeography,
|
|
||||||
TestIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { mobileAds };
|
|
||||||
export default defaultExport;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
export enum NotificationFeedbackType {
|
|
||||||
Success = 'success',
|
|
||||||
Warning = 'warning',
|
|
||||||
Error = 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ImpactFeedbackStyle {
|
|
||||||
Light = 'light',
|
|
||||||
Medium = 'medium',
|
|
||||||
Heavy = 'heavy',
|
|
||||||
Soft = 'soft',
|
|
||||||
Rigid = 'rigid',
|
|
||||||
}
|
|
||||||
|
|
||||||
const vibrationPatterns: Record<
|
|
||||||
NotificationFeedbackType | ImpactFeedbackStyle | 'selection',
|
|
||||||
VibratePattern
|
|
||||||
> = {
|
|
||||||
[NotificationFeedbackType.Success]: [40, 100, 40],
|
|
||||||
[NotificationFeedbackType.Warning]: [50, 100, 50],
|
|
||||||
[NotificationFeedbackType.Error]: [60, 100, 60, 100, 60],
|
|
||||||
[ImpactFeedbackStyle.Light]: [40],
|
|
||||||
[ImpactFeedbackStyle.Medium]: [50],
|
|
||||||
[ImpactFeedbackStyle.Heavy]: [60],
|
|
||||||
[ImpactFeedbackStyle.Soft]: [35],
|
|
||||||
[ImpactFeedbackStyle.Rigid]: [45],
|
|
||||||
selection: [50],
|
|
||||||
};
|
|
||||||
|
|
||||||
function isVibrationAvailable(): boolean {
|
|
||||||
return (
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
'navigator' in window &&
|
|
||||||
'vibrate' in navigator
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selectionAsync = async (): Promise<void> => {
|
|
||||||
if (!isVibrationAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.vibrate(vibrationPatterns.selection);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const notificationAsync = async (
|
|
||||||
type: NotificationFeedbackType = NotificationFeedbackType.Success
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!isVibrationAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.vibrate(vibrationPatterns[type]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const impactAsync = async (
|
|
||||||
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!isVibrationAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.vibrate(vibrationPatterns[style]);
|
|
||||||
};
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
export enum MediaTypeOptions {
|
|
||||||
All = 'All',
|
|
||||||
Images = 'Images',
|
|
||||||
Videos = 'Videos',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum UIImagePickerPresentationStyle {
|
|
||||||
FULL_SCREEN = 0,
|
|
||||||
PAGE_SHEET = 1,
|
|
||||||
FORM_SHEET = 2,
|
|
||||||
CURRENT_CONTEXT = 3,
|
|
||||||
OVERFUL_SCREEN = 4,
|
|
||||||
POPOVER = 5,
|
|
||||||
AUTOMATIC = -2,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImagePickerAsset {
|
|
||||||
uri: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
type: 'image' | 'video' | undefined;
|
|
||||||
fileName: string | null;
|
|
||||||
fileSize: number | undefined;
|
|
||||||
mimeType: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImagePickerResult {
|
|
||||||
canceled: boolean;
|
|
||||||
assets: ImagePickerAsset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImagePickerOptions {
|
|
||||||
mediaTypes?: MediaTypeOptions;
|
|
||||||
allowsEditing?: boolean;
|
|
||||||
quality?: number;
|
|
||||||
allowsMultipleSelection?: boolean;
|
|
||||||
base64?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAcceptString(mediaTypes?: MediaTypeOptions): string {
|
|
||||||
switch (mediaTypes) {
|
|
||||||
case MediaTypeOptions.Images:
|
|
||||||
return 'image/*';
|
|
||||||
case MediaTypeOptions.Videos:
|
|
||||||
return 'video/*';
|
|
||||||
default:
|
|
||||||
return 'image/*,video/*';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFileViaInput(
|
|
||||||
accept: string,
|
|
||||||
capture: boolean,
|
|
||||||
multiple: boolean
|
|
||||||
): Promise<ImagePickerResult> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = accept;
|
|
||||||
input.multiple = multiple;
|
|
||||||
if (capture) input.setAttribute('capture', 'environment');
|
|
||||||
input.style.display = 'none';
|
|
||||||
document.body.appendChild(input);
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
const cleanup = () => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
document.body.removeChild(input);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
const files = input.files;
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: true, assets: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const promises = Array.from(files).map(
|
|
||||||
(file) =>
|
|
||||||
new Promise<ImagePickerAsset>((resolveAsset) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const uri = reader.result as string;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
resolveAsset({
|
|
||||||
uri,
|
|
||||||
width: img.naturalWidth,
|
|
||||||
height: img.naturalHeight,
|
|
||||||
type: file.type.startsWith('video') ? 'video' : 'image',
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
mimeType: file.type || undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
resolveAsset({
|
|
||||||
uri,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
type: file.type.startsWith('video') ? 'video' : 'image',
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
mimeType: file.type || undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
img.src = uri;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
void Promise.all(promises).then((assets) => {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: false, assets });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle cancel (user closes the file dialog without selecting)
|
|
||||||
window.addEventListener(
|
|
||||||
'focus',
|
|
||||||
() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!resolved) {
|
|
||||||
cleanup();
|
|
||||||
resolve({ canceled: true, assets: [] });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
input.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function launchImageLibraryAsync(
|
|
||||||
options?: ImagePickerOptions
|
|
||||||
): Promise<ImagePickerResult> {
|
|
||||||
return pickFileViaInput(
|
|
||||||
getAcceptString(options?.mediaTypes),
|
|
||||||
false,
|
|
||||||
options?.allowsMultipleSelection ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function launchCameraAsync(
|
|
||||||
options?: ImagePickerOptions
|
|
||||||
): Promise<ImagePickerResult> {
|
|
||||||
return pickFileViaInput(
|
|
||||||
getAcceptString(options?.mediaTypes),
|
|
||||||
true,
|
|
||||||
options?.allowsMultipleSelection ?? false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grantedPermission = {
|
|
||||||
status: 'granted' as const,
|
|
||||||
granted: true,
|
|
||||||
canAskAgain: true,
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function requestMediaLibraryPermissionsAsync() {
|
|
||||||
return grantedPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMediaLibraryPermissionsAsync() {
|
|
||||||
return grantedPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestCameraPermissionsAsync() {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
|
||||||
return grantedPermission;
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
status: 'denied' as const,
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: true,
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCameraPermissionsAsync() {
|
|
||||||
try {
|
|
||||||
const result = await navigator.permissions.query({
|
|
||||||
name: 'camera' as PermissionName,
|
|
||||||
});
|
|
||||||
const granted = result.state === 'granted';
|
|
||||||
return {
|
|
||||||
status: granted ? ('granted' as const) : ('denied' as const),
|
|
||||||
granted,
|
|
||||||
canAskAgain: result.state !== 'denied',
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return grantedPermission;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export async function openURL(url: string): Promise<true> {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function canOpenURL(_url: string): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInitialURL(): string {
|
|
||||||
return typeof window !== 'undefined' ? window.location.href : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createURL(
|
|
||||||
path: string,
|
|
||||||
namedParameters?: { queryParams?: Record<string, string> }
|
|
||||||
): string {
|
|
||||||
const base = typeof window !== 'undefined' ? window.location.origin : '';
|
|
||||||
const url = new URL(path.startsWith('/') ? path : `/${path}`, base);
|
|
||||||
if (namedParameters?.queryParams) {
|
|
||||||
for (const [key, value] of Object.entries(namedParameters.queryParams)) {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parse(url: string) {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const queryParams: Record<string, string> = {};
|
|
||||||
parsed.searchParams.forEach((value, key) => {
|
|
||||||
queryParams[key] = value;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
path: parsed.pathname,
|
|
||||||
queryParams,
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
scheme: parsed.protocol.replace(':', ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addEventListener(
|
|
||||||
_type: string,
|
|
||||||
handler: (event: { url: string }) => void
|
|
||||||
) {
|
|
||||||
const listener = () => handler({ url: window.location.href });
|
|
||||||
window.addEventListener('popstate', listener);
|
|
||||||
return { remove: () => window.removeEventListener('popstate', listener) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useURL(): string | null {
|
|
||||||
return typeof window !== 'undefined' ? window.location.href : null;
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import type { LocationGeocodedAddress } from 'expo-location';
|
|
||||||
|
|
||||||
type Coords = { latitude: number; longitude: number };
|
|
||||||
|
|
||||||
export enum LocationAccuracy {
|
|
||||||
Lowest = 1,
|
|
||||||
Low = 2,
|
|
||||||
Balanced = 3,
|
|
||||||
High = 4,
|
|
||||||
Highest = 5,
|
|
||||||
BestForNavigation = 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PermissionStatus {
|
|
||||||
DENIED = 'denied',
|
|
||||||
GRANTED = 'granted',
|
|
||||||
UNDETERMINED = 'undetermined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationObject {
|
|
||||||
coords: {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
altitude: number | null;
|
|
||||||
accuracy: number | null;
|
|
||||||
altitudeAccuracy: number | null;
|
|
||||||
heading: number | null;
|
|
||||||
speed: number | null;
|
|
||||||
};
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationOptions {
|
|
||||||
accuracy?: LocationAccuracy;
|
|
||||||
distanceInterval?: number;
|
|
||||||
timeInterval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationSubscription {
|
|
||||||
remove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLocationObject(position: GeolocationPosition): LocationObject {
|
|
||||||
return {
|
|
||||||
coords: {
|
|
||||||
latitude: position.coords.latitude,
|
|
||||||
longitude: position.coords.longitude,
|
|
||||||
altitude: position.coords.altitude,
|
|
||||||
accuracy: position.coords.accuracy,
|
|
||||||
altitudeAccuracy: position.coords.altitudeAccuracy,
|
|
||||||
heading: position.coords.heading,
|
|
||||||
speed: position.coords.speed,
|
|
||||||
},
|
|
||||||
timestamp: position.timestamp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPermissionResponse(state: PermissionState) {
|
|
||||||
const granted = state === 'granted';
|
|
||||||
return {
|
|
||||||
status: granted
|
|
||||||
? PermissionStatus.GRANTED
|
|
||||||
: state === 'prompt'
|
|
||||||
? PermissionStatus.UNDETERMINED
|
|
||||||
: PermissionStatus.DENIED,
|
|
||||||
granted,
|
|
||||||
canAskAgain: state !== 'denied',
|
|
||||||
expires: 'never' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCurrentPositionAsync(
|
|
||||||
options?: LocationOptions
|
|
||||||
): Promise<LocationObject> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
reject(new Error('Geolocation is not available in this browser'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(position) => resolve(toLocationObject(position)),
|
|
||||||
(error) => reject(new Error(error.message)),
|
|
||||||
{
|
|
||||||
enableHighAccuracy:
|
|
||||||
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
|
|
||||||
timeout: 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function watchPositionAsync(
|
|
||||||
options: LocationOptions | null,
|
|
||||||
callback: (location: LocationObject) => void
|
|
||||||
): Promise<LocationSubscription> {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
throw new Error('Geolocation is not available in this browser');
|
|
||||||
}
|
|
||||||
const watchId = navigator.geolocation.watchPosition(
|
|
||||||
(position) => callback(toLocationObject(position)),
|
|
||||||
() => {},
|
|
||||||
{
|
|
||||||
enableHighAccuracy:
|
|
||||||
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return { remove: () => navigator.geolocation.clearWatch(watchId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLastKnownPositionAsync(): Promise<LocationObject | null> {
|
|
||||||
try {
|
|
||||||
return await getCurrentPositionAsync();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestForegroundPermissionsAsync() {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
return toPermissionResponse('denied');
|
|
||||||
}
|
|
||||||
return new Promise<ReturnType<typeof toPermissionResponse>>((resolve) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
() => resolve(toPermissionResponse('granted')),
|
|
||||||
() => resolve(toPermissionResponse('denied')),
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestBackgroundPermissionsAsync() {
|
|
||||||
return toPermissionResponse('denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getForegroundPermissionsAsync() {
|
|
||||||
try {
|
|
||||||
const result = await navigator.permissions.query({
|
|
||||||
name: 'geolocation',
|
|
||||||
});
|
|
||||||
return toPermissionResponse(result.state);
|
|
||||||
} catch {
|
|
||||||
return toPermissionResponse('prompt');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBackgroundPermissionsAsync() {
|
|
||||||
return toPermissionResponse('denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function geocodeAsync(
|
|
||||||
_address: string
|
|
||||||
): Promise<{ latitude: number; longitude: number; accuracy: number }[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reverseGeocodeAsync({
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
}: Coords): Promise<LocationGeocodedAddress[]> {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
city: 'Sample City',
|
|
||||||
street: 'Main Street',
|
|
||||||
district: 'Downtown',
|
|
||||||
region: 'Sample State',
|
|
||||||
postalCode: '12345',
|
|
||||||
country: 'Sample Country',
|
|
||||||
isoCountryCode: 'SC',
|
|
||||||
name: `Location at ${latitude.toFixed(4)}, ${longitude.toFixed(4)}`,
|
|
||||||
streetNumber: '123',
|
|
||||||
subregion: null,
|
|
||||||
timezone: null,
|
|
||||||
formattedAddress: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasServicesEnabledAsync(): Promise<boolean> {
|
|
||||||
return typeof navigator !== 'undefined' && !!navigator.geolocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isBackgroundLocationAvailableAsync(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NativeLocation = require('expo-location/build') as Record<string, unknown>;
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
...NativeLocation,
|
|
||||||
LocationAccuracy,
|
|
||||||
PermissionStatus,
|
|
||||||
getCurrentPositionAsync,
|
|
||||||
watchPositionAsync,
|
|
||||||
getLastKnownPositionAsync,
|
|
||||||
requestForegroundPermissionsAsync,
|
|
||||||
requestBackgroundPermissionsAsync,
|
|
||||||
getForegroundPermissionsAsync,
|
|
||||||
getBackgroundPermissionsAsync,
|
|
||||||
geocodeAsync,
|
|
||||||
reverseGeocodeAsync,
|
|
||||||
hasServicesEnabledAsync,
|
|
||||||
isBackgroundLocationAvailableAsync,
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.default = module.exports;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import WebMapView, * as WebMaps from '@teovilla/react-native-web-maps';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const PROVIDER_GOOGLE = 'google';
|
|
||||||
export const PROVIDER_DEFAULT = undefined;
|
|
||||||
|
|
||||||
const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY;
|
|
||||||
|
|
||||||
const MapView = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
|
||||||
return (
|
|
||||||
// @ts-expect-error — library default export is not typed as a valid JSX component
|
|
||||||
<WebMapView
|
|
||||||
ref={ref}
|
|
||||||
provider={PROVIDER_GOOGLE}
|
|
||||||
googleMapsApiKey={GOOGLE_MAPS_API_KEY}
|
|
||||||
{...props}
|
|
||||||
options={{
|
|
||||||
disableDefaultUI: true,
|
|
||||||
zoomControl: false,
|
|
||||||
streetViewControl: false,
|
|
||||||
mapTypeControl: false,
|
|
||||||
fullscreenControl: false,
|
|
||||||
rotateControl: false,
|
|
||||||
scaleControl: false,
|
|
||||||
keyboardShortcuts: false,
|
|
||||||
...(props.options as Record<string, unknown>),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(MapView, {
|
|
||||||
...WebMaps,
|
|
||||||
PROVIDER_GOOGLE,
|
|
||||||
PROVIDER_DEFAULT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The library namespace export doesn't declare these members but they exist at runtime
|
|
||||||
const Maps = WebMaps as Record<string, unknown>;
|
|
||||||
export const Marker = Maps.Marker;
|
|
||||||
export const Callout = Maps.Callout;
|
|
||||||
export const Polyline = Maps.Polyline;
|
|
||||||
export const Polygon = Maps.Polygon;
|
|
||||||
export const Circle = Maps.Circle;
|
|
||||||
export const Overlay = Maps.Overlay;
|
|
||||||
export const Heatmap = Maps.Heatmap;
|
|
||||||
export const UrlTile = Maps.UrlTile;
|
|
||||||
export const WMSTile = Maps.WMSTile;
|
|
||||||
export const LocalTile = Maps.LocalTile;
|
|
||||||
|
|
||||||
export default MapView;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import type {
|
|
||||||
NotificationRequest,
|
|
||||||
PermissionResponse,
|
|
||||||
} from 'expo-notifications/src/Notifications.types';
|
|
||||||
import type { NotificationHandler } from 'expo-notifications/src/NotificationsHandler';
|
|
||||||
import { toast } from 'sonner-native';
|
|
||||||
import * as Notifications from 'expo-notifications';
|
|
||||||
const { PermissionStatus } = Notifications;
|
|
||||||
|
|
||||||
const scheduledNotifications = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
request: NotificationRequest;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
export const setNotificationHandler = (_handler: NotificationHandler | null): void => {
|
|
||||||
//no-op
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestPermissionsAsync = async (): Promise<PermissionResponse> => {
|
|
||||||
return {
|
|
||||||
status: PermissionStatus.GRANTED,
|
|
||||||
expires: 'never',
|
|
||||||
granted: true,
|
|
||||||
canAskAgain: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scheduleNotificationAsync = async (
|
|
||||||
notificationRequest: NotificationRequest
|
|
||||||
): Promise<string> => {
|
|
||||||
const { content, trigger: _trigger } = notificationRequest;
|
|
||||||
const { title, body } = content;
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
if (title && body) {
|
|
||||||
message = `${title}\n${body}`;
|
|
||||||
} else if (title) {
|
|
||||||
message = title;
|
|
||||||
} else if (body) {
|
|
||||||
message = `Expo Go\n${body}`;
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifier = Math.random().toString(36).substr(2, 9);
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
toast(message);
|
|
||||||
scheduledNotifications.delete(identifier);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
scheduledNotifications.set(identifier, {
|
|
||||||
timeoutId,
|
|
||||||
request: notificationRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
return identifier;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelAllScheduledNotificationsAsync = async (): Promise<void> => {
|
|
||||||
for (const { timeoutId } of scheduledNotifications.values()) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
scheduledNotifications.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelScheduledNotificationAsync = async (identifier: string): Promise<void> => {
|
|
||||||
const scheduledNotification = scheduledNotifications.get(identifier);
|
|
||||||
if (scheduledNotification) {
|
|
||||||
clearTimeout(scheduledNotification.timeoutId);
|
|
||||||
scheduledNotifications.delete(identifier);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllScheduledNotificationsAsync = async (): Promise<NotificationRequest[]> => {
|
|
||||||
return Array.from(scheduledNotifications.values()).map(({ request }) => request);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { RefreshControl } from 'react-native-web-refresh-control';
|
|
||||||
|
|
||||||
export default RefreshControl;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export {
|
|
||||||
SafeAreaProvider,
|
|
||||||
SafeAreaInsetsContext,
|
|
||||||
SafeAreaFrameContext,
|
|
||||||
useSafeAreaFrame,
|
|
||||||
initialWindowMetrics,
|
|
||||||
} from 'react-native-safe-area-context/lib/commonjs';
|
|
||||||
import { useSafeAreaInsets as useNativeSafeAreaInsets } from 'react-native-safe-area-context/lib/commonjs';
|
|
||||||
|
|
||||||
export { SafeAreaView } from './SafeAreaView.web';
|
|
||||||
|
|
||||||
export const useSafeAreaInsets = () => {
|
|
||||||
const isTabletAndAbove =
|
|
||||||
typeof window !== 'undefined' ? window.self !== window.top : true;
|
|
||||||
const insets = useNativeSafeAreaInsets();
|
|
||||||
if (isTabletAndAbove) {
|
|
||||||
return {
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 64,
|
|
||||||
bottom: 34,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return insets;
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import RNScrollView from 'react-native-web/dist/exports/ScrollView';
|
|
||||||
|
|
||||||
export const ScrollView = React.forwardRef((props: Record<string, any>, ref: React.Ref<any>) => {
|
|
||||||
const extendedStyle = useMemo(() => {
|
|
||||||
if (props.horizontal) {
|
|
||||||
return [{flexGrow: 0}, props.style]
|
|
||||||
}
|
|
||||||
return props.style
|
|
||||||
}, [props.horizontal, props.style])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RNScrollView
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
style={extendedStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ScrollView.displayName = 'ScrollView';
|
|
||||||
|
|
||||||
export default ScrollView;
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
const VALUE_BYTES_LIMIT = 2048;
|
|
||||||
|
|
||||||
const KEYCHAIN_CONSTANTS = {
|
|
||||||
AFTER_FIRST_UNLOCK: 0,
|
|
||||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 1,
|
|
||||||
ALWAYS: 2,
|
|
||||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 3,
|
|
||||||
ALWAYS_THIS_DEVICE_ONLY: 4,
|
|
||||||
WHEN_UNLOCKED: 5,
|
|
||||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type KeychainAccessibilityConstant = number;
|
|
||||||
export const {
|
|
||||||
AFTER_FIRST_UNLOCK,
|
|
||||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
||||||
ALWAYS,
|
|
||||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
|
|
||||||
ALWAYS_THIS_DEVICE_ONLY,
|
|
||||||
WHEN_UNLOCKED,
|
|
||||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
||||||
} = KEYCHAIN_CONSTANTS;
|
|
||||||
|
|
||||||
export type SecureStoreOptions = {
|
|
||||||
keychainService?: string;
|
|
||||||
requireAuthentication?: boolean;
|
|
||||||
authenticationPrompt?: string;
|
|
||||||
keychainAccessible?: KeychainAccessibilityConstant;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isValidValue(value: string) {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (new Blob([value]).size > VALUE_BYTES_LIMIT) {
|
|
||||||
// biome-ignore lint/suspicious/noConsole: useful for debugging
|
|
||||||
console.warn(
|
|
||||||
`Value being stored in SecureStore is larger than ${VALUE_BYTES_LIMIT} bytes and it may not be stored successfully.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStorageKey(key: string): string {
|
|
||||||
return `_create_secure_store_${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isAvailableAsync(): Promise<boolean> {
|
|
||||||
const testKey = '__SECURE_STORE_AVAILABILITY_TEST_KEY__';
|
|
||||||
try {
|
|
||||||
localStorage.setItem(testKey, 'test');
|
|
||||||
if (localStorage.getItem(testKey) !== 'test') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
localStorage.removeItem(testKey);
|
|
||||||
return localStorage.getItem(testKey) === null;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteItemAsync(
|
|
||||||
key: string,
|
|
||||||
_options: SecureStoreOptions = {}
|
|
||||||
): Promise<void> {
|
|
||||||
localStorage.removeItem(getStorageKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getItemAsync(
|
|
||||||
key: string,
|
|
||||||
_options: SecureStoreOptions = {}
|
|
||||||
): Promise<string | null> {
|
|
||||||
return localStorage.getItem(getStorageKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setItemAsync(
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
_options: SecureStoreOptions = {}
|
|
||||||
): Promise<void> {
|
|
||||||
if (!isValidValue(value)) {
|
|
||||||
throw new Error(
|
|
||||||
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
localStorage.setItem(getStorageKey(key), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setItem(
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
_options: SecureStoreOptions = {}
|
|
||||||
): void {
|
|
||||||
if (!isValidValue(value)) {
|
|
||||||
throw new Error(
|
|
||||||
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
localStorage.setItem(getStorageKey(key), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getItem(
|
|
||||||
key: string,
|
|
||||||
_options: SecureStoreOptions = {}
|
|
||||||
): string | null {
|
|
||||||
return localStorage.getItem(getStorageKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canUseBiometricAuthentication(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { Appearance, useColorScheme } from "react-native";
|
|
||||||
import {
|
|
||||||
StatusBar as ExpoStatusBar,
|
|
||||||
type StatusBarStyle,
|
|
||||||
type StatusBarAnimation,
|
|
||||||
type StatusBarProps,
|
|
||||||
} from "expo-status-bar";
|
|
||||||
import * as ExpoSB from "expo-status-bar";
|
|
||||||
|
|
||||||
function postColorToParent(color: string) {
|
|
||||||
try {
|
|
||||||
if (typeof window !== "undefined" && "parent" in window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{ type: "sandbox:mobile:statusbarcolor", color, timestamp: Date.now() },
|
|
||||||
"*"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.warn("Color was not sent to parent");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function styleToBarColor(
|
|
||||||
style: StatusBarStyle | "auto" | "inverted" = "auto",
|
|
||||||
colorScheme = Appearance.getColorScheme()
|
|
||||||
) {
|
|
||||||
const actual = colorScheme ?? "light";
|
|
||||||
let resolved:
|
|
||||||
| Exclude<StatusBarStyle, "auto" | "inverted">
|
|
||||||
| "light"
|
|
||||||
| "dark" = style as any;
|
|
||||||
|
|
||||||
if (style === "auto") resolved = actual === "light" ? "dark" : "light";
|
|
||||||
else if (style === "inverted")
|
|
||||||
resolved = actual === "light" ? "light" : "dark";
|
|
||||||
|
|
||||||
return resolved === "light" ? "#FFFFFF" : "#000000";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBar = React.forwardRef<any, StatusBarProps>(
|
|
||||||
function StatusBar({ style = "auto", ...props }, _ref) {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
postColorToParent(styleToBarColor(style, colorScheme));
|
|
||||||
}, [style, colorScheme]);
|
|
||||||
|
|
||||||
return <ExpoStatusBar style={style} {...props} />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const setStatusBarStyle = (style: StatusBarStyle, animated?: boolean) =>
|
|
||||||
ExpoSB.setStatusBarStyle(style, animated);
|
|
||||||
|
|
||||||
export const setStatusBarHidden = (
|
|
||||||
hidden: boolean,
|
|
||||||
animation?: StatusBarAnimation
|
|
||||||
) => ExpoSB.setStatusBarHidden(hidden, animation);
|
|
||||||
|
|
||||||
export const setStatusBarBackgroundColor = (
|
|
||||||
backgroundColor: string,
|
|
||||||
animated?: boolean
|
|
||||||
) => ExpoSB.setStatusBarBackgroundColor(backgroundColor as any, animated);
|
|
||||||
|
|
||||||
export const setStatusBarNetworkActivityIndicatorVisible = (visible: boolean) =>
|
|
||||||
ExpoSB.setStatusBarNetworkActivityIndicatorVisible(visible);
|
|
||||||
|
|
||||||
export const setStatusBarTranslucent = (translucent: boolean) =>
|
|
||||||
ExpoSB.setStatusBarTranslucent(translucent);
|
|
||||||
|
|
||||||
export type { StatusBarStyle, StatusBarAnimation, StatusBarProps };
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Tabs as ExpoTabs } from 'expo-router/build/layouts/Tabs';
|
|
||||||
import { merge } from 'lodash';
|
|
||||||
import { forwardRef } from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
export const BASE_TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 49 : 56;
|
|
||||||
|
|
||||||
export const Tabs = forwardRef((props: any, ref: any) => {
|
|
||||||
const isInIframe = typeof window !== 'undefined' ? window.self !== window.top : false;
|
|
||||||
const height = props.screenOptions.tabBarStyle?.height || (BASE_TAB_BAR_HEIGHT + (isInIframe ? 34 : 0));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExpoTabs
|
|
||||||
{...props}
|
|
||||||
screenOptions={merge(props.screenOptions, {
|
|
||||||
tabBarStyle: merge(props.screenOptions.tabBarStyle, { height }),
|
|
||||||
})}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
(Tabs as any).Screen = ExpoTabs.Screen;
|
|
||||||
|
|
||||||
export default Tabs;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
export enum WebBrowserResultType {
|
|
||||||
CANCEL = 'cancel',
|
|
||||||
DISMISS = 'dismiss',
|
|
||||||
OPENED = 'opened',
|
|
||||||
LOCKED = 'locked',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebBrowserResult {
|
|
||||||
type: WebBrowserResultType;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _openWindow: Window | null = null;
|
|
||||||
|
|
||||||
export async function openBrowserAsync(
|
|
||||||
url: string,
|
|
||||||
_options?: {
|
|
||||||
toolbarColor?: string;
|
|
||||||
controlsColor?: string;
|
|
||||||
secondaryToolbarColor?: string;
|
|
||||||
enableBarCollapsing?: boolean;
|
|
||||||
showTitle?: boolean;
|
|
||||||
enableDefaultShareMenuItem?: boolean;
|
|
||||||
windowName?: string;
|
|
||||||
windowFeatures?: string;
|
|
||||||
}
|
|
||||||
): Promise<WebBrowserResult> {
|
|
||||||
_openWindow = window.open(url, '_blank');
|
|
||||||
return { type: WebBrowserResultType.OPENED };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openAuthSessionAsync(
|
|
||||||
url: string,
|
|
||||||
_redirectUrl?: string,
|
|
||||||
_options?: { showInRecents?: boolean }
|
|
||||||
): Promise<WebBrowserResult & { url?: string }> {
|
|
||||||
const authWindow = window.open(url, '_blank');
|
|
||||||
if (!authWindow) {
|
|
||||||
return { type: WebBrowserResultType.CANCEL };
|
|
||||||
}
|
|
||||||
return { type: WebBrowserResultType.OPENED };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismissBrowser(): void {
|
|
||||||
if (_openWindow && !_openWindow.closed) {
|
|
||||||
_openWindow.close();
|
|
||||||
_openWindow = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function warmUpAsync(): Promise<void> {}
|
|
||||||
export async function coolDownAsync(): Promise<void> {}
|
|
||||||
export async function mayInitWithUrlAsync(
|
|
||||||
_url: string
|
|
||||||
): Promise<{ servicePackage: string | null }> {
|
|
||||||
return { servicePackage: null };
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
||||||
import type { StyleProp, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
source: { uri?: string; html?: string; headers?: Record<string, string> };
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
injectedJavaScript?: string;
|
|
||||||
onMessage?: (ev: { nativeEvent: { data: string } }) => void;
|
|
||||||
onLoadStart?: () => void;
|
|
||||||
onLoad?: () => void;
|
|
||||||
onLoadEnd?: () => void;
|
|
||||||
onError?: (syntheticEvent: {
|
|
||||||
nativeEvent: { code: number; description: string };
|
|
||||||
}) => void;
|
|
||||||
onNavigationStateChange?: (navState: {
|
|
||||||
url: string;
|
|
||||||
loading: boolean;
|
|
||||||
canGoBack: boolean;
|
|
||||||
canGoForward: boolean;
|
|
||||||
}) => void;
|
|
||||||
onShouldStartLoadWithRequest?: (event: { url: string }) => boolean;
|
|
||||||
scrollEnabled?: boolean;
|
|
||||||
bounces?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web-based implementation of React Native WebView using iframe
|
|
||||||
*/
|
|
||||||
export const WebView = forwardRef((props: Props, ref) => {
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
const {
|
|
||||||
source,
|
|
||||||
style,
|
|
||||||
injectedJavaScript,
|
|
||||||
onMessage,
|
|
||||||
onLoadStart,
|
|
||||||
onLoad,
|
|
||||||
onLoadEnd,
|
|
||||||
onNavigationStateChange,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
onMessage?.({ nativeEvent: { data: event.data } });
|
|
||||||
};
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, [onMessage]);
|
|
||||||
|
|
||||||
// Imperative handle to match RN WebView API
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
injectJavaScript: (js: string) => {
|
|
||||||
iframeRef.current?.contentWindow?.postMessage(js, '*');
|
|
||||||
},
|
|
||||||
goBack: () => {
|
|
||||||
iframeRef.current?.contentWindow?.history.back();
|
|
||||||
},
|
|
||||||
goForward: () => {
|
|
||||||
iframeRef.current?.contentWindow?.history.forward();
|
|
||||||
},
|
|
||||||
reload: () => {
|
|
||||||
iframeRef.current?.contentWindow?.location.reload();
|
|
||||||
},
|
|
||||||
stopLoading: () => {
|
|
||||||
// Not directly possible with iframe
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const src = source.html
|
|
||||||
? `data:text/html;charset=utf-8,${encodeURIComponent(source.html)}`
|
|
||||||
: source.uri;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
src={src}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
overflow: props.scrollEnabled === false ? 'hidden' : 'auto',
|
|
||||||
...(style as Record<string, unknown>),
|
|
||||||
}}
|
|
||||||
allow="third-party-cookies"
|
|
||||||
onLoad={(e) => {
|
|
||||||
onLoadStart?.();
|
|
||||||
onLoad?.();
|
|
||||||
onLoadEnd?.();
|
|
||||||
if (injectedJavaScript) {
|
|
||||||
iframeRef.current?.contentWindow?.postMessage(
|
|
||||||
injectedJavaScript,
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const win = e.currentTarget.contentWindow;
|
|
||||||
if (win) {
|
|
||||||
onNavigationStateChange?.({
|
|
||||||
url: win.location.href,
|
|
||||||
loading: false,
|
|
||||||
canGoBack: win.history.length > 1,
|
|
||||||
canGoForward: win.history.length > 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default WebView;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Binary file not shown.
@@ -1,105 +0,0 @@
|
|||||||
import React, { Component, type ReactNode } from 'react';
|
|
||||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postErrorToParent(error: Error) {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'sandbox:error:detected',
|
|
||||||
error: {
|
|
||||||
message: error.message,
|
|
||||||
name: error.name || 'Error',
|
|
||||||
stack: error.stack || '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function postErrorResolvedToParent() {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
|
||||||
window.parent.postMessage({ type: 'sandbox:error:resolved' }, '*');
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
|
||||||
state: State = { hasError: false, error: null };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error) {
|
|
||||||
postErrorToParent(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Something went wrong</Text>
|
|
||||||
<Text style={styles.message}>
|
|
||||||
{this.state.error?.message ?? 'An unexpected error occurred'}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.button}
|
|
||||||
onPress={() => {
|
|
||||||
this.setState({ hasError: false, error: null });
|
|
||||||
postErrorResolvedToParent();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>Try again</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#18191B',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#959697',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: '#18191B',
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { usePathname } from "expo-router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
const VISITOR_ID_KEY = "anything_analytics_visitor_id";
|
|
||||||
|
|
||||||
// Mirror the gating used by Sentry / the TestFlight logger: only emit from
|
|
||||||
// real (production) builds, never from the in-builder dev runtime.
|
|
||||||
function isActive(): boolean {
|
|
||||||
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateVisitorId(): string {
|
|
||||||
const rand = () => Math.random().toString(36).slice(2);
|
|
||||||
return `${rand()}${rand()}`.slice(0, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
let visitorIdPromise: Promise<string> | null = null;
|
|
||||||
|
|
||||||
// Stable, anonymous, per-install id. Not a secret, so AsyncStorage (not the
|
|
||||||
// keychain) is the right home. Generated once and reused for the install.
|
|
||||||
function getVisitorId(): Promise<string> {
|
|
||||||
if (!visitorIdPromise) {
|
|
||||||
visitorIdPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const existing = await AsyncStorage.getItem(VISITOR_ID_KEY);
|
|
||||||
if (existing) return existing;
|
|
||||||
const created = generateVisitorId();
|
|
||||||
await AsyncStorage.setItem(VISITOR_ID_KEY, created);
|
|
||||||
return created;
|
|
||||||
} catch {
|
|
||||||
// If persistence fails, fall back to a session-scoped id so the
|
|
||||||
// current run still attributes its views to one visitor.
|
|
||||||
return generateVisitorId();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
return visitorIdPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Records one screen view per route change. The endpoint enforces the global
|
|
||||||
// flag and the project's analytics opt-in, dropping events (204) when off, so
|
|
||||||
// this always fires and the server decides whether to keep it.
|
|
||||||
export function ScreenViewTracker() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive()) return;
|
|
||||||
|
|
||||||
const endpoint = process.env.EXPO_PUBLIC_ANALYTICS_ENDPOINT;
|
|
||||||
const host = process.env.EXPO_PUBLIC_HOST;
|
|
||||||
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
|
||||||
if (!endpoint || !host || !projectGroupId || !pathname) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const visitorId = await getVisitorId();
|
|
||||||
if (cancelled) return;
|
|
||||||
await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
d: host,
|
|
||||||
p: pathname,
|
|
||||||
pgid: projectGroupId,
|
|
||||||
vid: visitorId,
|
|
||||||
os: Platform.OS,
|
|
||||||
dt: "mobile",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Analytics must never crash or block the host app.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import LauncherMenuContainer from '@anythingai/app/screens/launcher-menu';
|
|
||||||
import React from 'react';
|
|
||||||
import { StyleSheet, View } from 'react-native';
|
|
||||||
|
|
||||||
const isExpoGo = globalThis.expo?.modules?.ExpoGo;
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
if (isExpoGo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{ ...StyleSheet.absoluteFillObject, zIndex: 9999 }}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
>
|
|
||||||
<LauncherMenuContainer />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, memo, useRef, useReducer } from "react";
|
|
||||||
import {
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
PanResponder,
|
|
||||||
Platform,
|
|
||||||
useWindowDimensions,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
useSharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
interpolate,
|
|
||||||
withTiming,
|
|
||||||
Easing,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import {
|
|
||||||
SafeAreaProvider,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from "react-native-safe-area-context";
|
|
||||||
import Svg, {
|
|
||||||
Path,
|
|
||||||
Rect,
|
|
||||||
Mask,
|
|
||||||
Circle,
|
|
||||||
G,
|
|
||||||
Defs,
|
|
||||||
ClipPath,
|
|
||||||
Line,
|
|
||||||
} from "react-native-svg";
|
|
||||||
import { NativeModule, requireNativeModule } from "expo-modules-core";
|
|
||||||
import { MotiView } from "moti";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { WebView } from "react-native-webview";
|
|
||||||
|
|
||||||
declare class AnythingLauncherModule extends NativeModule {
|
|
||||||
open(url: string): Promise<void>;
|
|
||||||
reset(): Promise<void>;
|
|
||||||
reload(): Promise<void>;
|
|
||||||
isWeb(): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TINT_DURATION_MS = 3000;
|
|
||||||
const CIRCLE_DIAMETER = 80;
|
|
||||||
const GAP = 16;
|
|
||||||
const ICON_SIZE = 18;
|
|
||||||
|
|
||||||
const getWebAppUrl = () => {
|
|
||||||
return process.env.EXPO_PUBLIC_APP_URL ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnythingApp =
|
|
||||||
Platform.OS !== "web" &&
|
|
||||||
process.env.EXPO_PUBLIC_IS_ANYTHING_APP === JSON.stringify(true);
|
|
||||||
|
|
||||||
const AnythingLauncher = isAnythingApp
|
|
||||||
? requireNativeModule<AnythingLauncherModule>("AnythingLauncherModule")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const RefreshIcon = memo(() => {
|
|
||||||
return (
|
|
||||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
|
||||||
<Path
|
|
||||||
d="M1.5 7.5s1.504-2.049 2.725-3.271a6.75 6.75 0 11-1.712 6.646M1.5 7.5V3m0 4.5H6"
|
|
||||||
stroke="#7E7F80"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const CloseIcon = memo(() => {
|
|
||||||
return (
|
|
||||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
|
||||||
<Path
|
|
||||||
d="M2.25 15.75l13.5-13.5M15.75 15.75L2.25 2.25"
|
|
||||||
stroke="#7E7F80"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const MobileViewIcon = memo(({ color }: { color: string }) => {
|
|
||||||
return (
|
|
||||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
|
||||||
<Path
|
|
||||||
d="M11.8125 1.5H6.1875C5.15197 1.5 4.3125 2.33947 4.3125 3.375V14.625C4.3125 15.6605 5.15197 16.5 6.1875 16.5H11.8125C12.848 16.5 13.6875 15.6605 13.6875 14.625V3.375C13.6875 2.33947 12.848 1.5 11.8125 1.5Z"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
x1={7.89575}
|
|
||||||
y1={13.3832}
|
|
||||||
x2={10.104}
|
|
||||||
y2={13.3832}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const WebViewIcon = memo(({ color }: { color: string }) => {
|
|
||||||
return (
|
|
||||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
|
||||||
<G clipPath="url(#clip0_340_2754)">
|
|
||||||
<Path
|
|
||||||
d="M15 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V12C1.5 12.8284 2.17157 13.5 3 13.5H15C15.8284 13.5 16.5 12.8284 16.5 12V3C16.5 2.17157 15.8284 1.5 15 1.5Z"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<Path
|
|
||||||
d="M9 13.5V16.5"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<Path
|
|
||||||
d="M6 16.5H12"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</G>
|
|
||||||
<Defs>
|
|
||||||
<ClipPath id="clip0_340_2754">
|
|
||||||
<Rect width={18} height={18} fill="white" />
|
|
||||||
</ClipPath>
|
|
||||||
</Defs>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ActiveDot = memo(() => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#000",
|
|
||||||
borderRadius: 50,
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
position: "absolute",
|
|
||||||
bottom: -8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const InstructionsOverlay = memo(
|
|
||||||
({
|
|
||||||
showTint,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
showTint: boolean;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}) => {
|
|
||||||
const r = CIRCLE_DIAMETER / 2;
|
|
||||||
const totalWidth = CIRCLE_DIAMETER * 2 + GAP;
|
|
||||||
const left = (width - totalWidth) / 2;
|
|
||||||
const cx1 = left + r;
|
|
||||||
const cx2 = cx1 + CIRCLE_DIAMETER + GAP;
|
|
||||||
const cy = height / 2 + 64;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MotiView
|
|
||||||
from={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: showTint ? 1 : 0 }}
|
|
||||||
transition={{ type: "timing", duration: 350 }}
|
|
||||||
style={menuStyles.holdTwoFingersTextContainer}
|
|
||||||
>
|
|
||||||
<Text style={menuStyles.holdTwoFingersText}>
|
|
||||||
Hold with 2 fingers for menu
|
|
||||||
</Text>
|
|
||||||
</MotiView>
|
|
||||||
<MotiView
|
|
||||||
from={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: showTint ? 1 : 0 }}
|
|
||||||
transition={{ type: "timing", duration: 350 }}
|
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
>
|
|
||||||
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
|
||||||
<Mask id="holes">
|
|
||||||
<Rect x="0" y="0" width={width} height={height} fill="white" />
|
|
||||||
<Circle cx={cx1} cy={cy} r={r} fill="black" />
|
|
||||||
<Circle cx={cx2} cy={cy} r={r} fill="black" />
|
|
||||||
</Mask>
|
|
||||||
|
|
||||||
<Rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
fill="black"
|
|
||||||
opacity={0.8}
|
|
||||||
mask="url(#holes)"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
</MotiView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
isLoading: boolean;
|
|
||||||
showTint: boolean;
|
|
||||||
showWebView: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action = { type: 'INITIALIZE', payload: { showWebView: boolean, showTint: boolean } } | { type: 'TOGGLE_WEB_VIEW' } | { type: 'HIDE_TINT' }
|
|
||||||
|
|
||||||
const initialState: State = { isLoading: true, showTint: false, showWebView: false };
|
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'INITIALIZE':
|
|
||||||
return { ...state, ...action.payload, isLoading: false };
|
|
||||||
case 'TOGGLE_WEB_VIEW':
|
|
||||||
return { ...state, showWebView: !state.showWebView };
|
|
||||||
case 'HIDE_TINT':
|
|
||||||
return { ...state, showTint: false };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const AnythingMenu = isAnythingApp
|
|
||||||
? ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
|
||||||
const { width, height } = useWindowDimensions();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!AnythingLauncher) {
|
|
||||||
throw new Error("AnythingLauncher is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isLoading) {
|
|
||||||
Promise.all([
|
|
||||||
AnythingLauncher.isWeb(),
|
|
||||||
AsyncStorage.getItem("hasSeenOnboarding"),
|
|
||||||
]).then(([isWeb, hasSeenOnboarding]) => {
|
|
||||||
dispatch({ type: 'INITIALIZE', payload: { showWebView: Boolean(isWeb), showTint: hasSeenOnboarding !== 'true' } });
|
|
||||||
}).catch(() => {
|
|
||||||
dispatch({ type: 'INITIALIZE', payload: { showWebView: false, showTint: false } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [state.isLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.isLoading && state.showTint) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
void AsyncStorage.setItem("hasSeenOnboarding", "true");
|
|
||||||
dispatch({ type: 'HIDE_TINT' });
|
|
||||||
}, TINT_DURATION_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
}, [state.isLoading, state.showTint])
|
|
||||||
|
|
||||||
const menuProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const hideMenuOffset = -(44 + 36 + insets.top + 10);
|
|
||||||
|
|
||||||
const exitApp = useCallback(() => {
|
|
||||||
void AnythingLauncher?.reset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reloadApp = useCallback(() => {
|
|
||||||
void AnythingLauncher?.reload();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleWebView = useCallback(() => {
|
|
||||||
dispatch({ type: 'TOGGLE_WEB_VIEW' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
const scale = interpolate(menuProgress.value, [0, 1], [1, 0.9]);
|
|
||||||
const shadowOpacity = interpolate(menuProgress.value, [0, 1], [0, 0.4]);
|
|
||||||
const elevation = interpolate(menuProgress.value, [0, 1], [0, 8]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
transform: [{ scale }],
|
|
||||||
shadowOpacity,
|
|
||||||
shadowOffset: { width: 0, height: 0 },
|
|
||||||
shadowRadius: 32,
|
|
||||||
elevation,
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const menuAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
const translateY = interpolate(
|
|
||||||
menuProgress.value,
|
|
||||||
[0, 1],
|
|
||||||
[hideMenuOffset, 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
transform: [{ translateY }],
|
|
||||||
};
|
|
||||||
}, [hideMenuOffset]);
|
|
||||||
|
|
||||||
const appPointerEvents = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
pointerEvents: menuProgress.value === 1 ? "box-only" : "auto",
|
|
||||||
};
|
|
||||||
}, [menuProgress]);
|
|
||||||
|
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const panResponder = useMemo(
|
|
||||||
() =>
|
|
||||||
PanResponder.create({
|
|
||||||
onStartShouldSetPanResponder: (evt, gestureState) => {
|
|
||||||
if (menuProgress.value === 1) {
|
|
||||||
menuProgress.value = withTiming(0, {
|
|
||||||
duration: 300,
|
|
||||||
easing: Easing.ease,
|
|
||||||
});
|
|
||||||
if (longPressTimer.current) {
|
|
||||||
clearTimeout(longPressTimer.current);
|
|
||||||
longPressTimer.current = null;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (gestureState.numberActiveTouches === 2) {
|
|
||||||
longPressTimer.current = setTimeout(() => {
|
|
||||||
menuProgress.value = withTiming(1, {
|
|
||||||
duration: 300,
|
|
||||||
easing: Easing.ease,
|
|
||||||
});
|
|
||||||
longPressTimer.current = null;
|
|
||||||
}, 500);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
onPanResponderEnd: (_evt, _gestureState) => {
|
|
||||||
if (longPressTimer.current) {
|
|
||||||
clearTimeout(longPressTimer.current);
|
|
||||||
longPressTimer.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[menuProgress.value]
|
|
||||||
);
|
|
||||||
|
|
||||||
const menuHeaderStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
...menuStyles.menuHeader,
|
|
||||||
marginTop: insets.top + 10,
|
|
||||||
}),
|
|
||||||
[insets.top]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (state.isLoading) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Animated.View
|
|
||||||
style={[styles.fill, animatedStyle]}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
{...panResponder.panHandlers}
|
|
||||||
>
|
|
||||||
<Animated.View style={[styles.fillContent, appPointerEvents]}>
|
|
||||||
{!state.showWebView ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<WebView
|
|
||||||
source={{ uri: getWebAppUrl() }}
|
|
||||||
style={[styles.webView, { paddingTop: insets.top }]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
</Animated.View>
|
|
||||||
<Animated.View style={[styles.menuContainer, menuAnimatedStyle]}>
|
|
||||||
<View style={menuStyles.menuContainerStyle}>
|
|
||||||
<View style={menuHeaderStyle}>
|
|
||||||
<View style={menuStyles.leftSection}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleWebView}
|
|
||||||
style={menuStyles.button}
|
|
||||||
>
|
|
||||||
<MobileViewIcon
|
|
||||||
color={state.showWebView ? "#7E7F80" : "#18191B"}
|
|
||||||
/>
|
|
||||||
{!state.showWebView && <ActiveDot />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleWebView}
|
|
||||||
style={menuStyles.button}
|
|
||||||
>
|
|
||||||
<WebViewIcon color={state.showWebView ? "#18191B" : "#7E7F80"} />
|
|
||||||
{state.showWebView && <ActiveDot />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View style={menuStyles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={reloadApp}
|
|
||||||
style={menuStyles.button}
|
|
||||||
>
|
|
||||||
<RefreshIcon />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={exitApp} style={menuStyles.button}>
|
|
||||||
<CloseIcon />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
<InstructionsOverlay
|
|
||||||
showTint={state.showTint}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: ({ children }: { children: React.ReactNode }) => children;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
|
||||||
fillContent: {
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
menuContainer: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
menuTouchable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
bottomSheetBackground: {
|
|
||||||
backgroundColor: "white",
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 20,
|
|
||||||
},
|
|
||||||
webViewContainer: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
zIndex: 2000,
|
|
||||||
},
|
|
||||||
webViewHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 18,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#e0e0e0",
|
|
||||||
},
|
|
||||||
webViewTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#18191B",
|
|
||||||
},
|
|
||||||
webViewCloseButton: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
webView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuStyles = StyleSheet.create({
|
|
||||||
menuContainerStyle: {
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 3.84,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
|
||||||
menuHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 18,
|
|
||||||
},
|
|
||||||
appIcon: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginRight: 20,
|
|
||||||
},
|
|
||||||
appTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#18191B",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
buttonContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 28,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
leftSection: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 28,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
holdTwoFingersTextContainer: {
|
|
||||||
zIndex: 1,
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
transform: [{ translateY: -24 }],
|
|
||||||
},
|
|
||||||
holdTwoFingersText: {
|
|
||||||
fontSize: 28,
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Screen({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<AnythingMenu>{children}</AnythingMenu>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { fetch as expoFetch } from 'expo/fetch';
|
|
||||||
|
|
||||||
const originalFetch = fetch;
|
|
||||||
const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
|
|
||||||
|
|
||||||
const getURLFromArgs = (...args: Parameters<typeof fetch>) => {
|
|
||||||
const [urlArg] = args;
|
|
||||||
if (typeof urlArg === 'string') {
|
|
||||||
return urlArg;
|
|
||||||
}
|
|
||||||
if (urlArg instanceof Request) {
|
|
||||||
return urlArg.url;
|
|
||||||
}
|
|
||||||
// URL type may not be in the fetch signature for all TS environments
|
|
||||||
if (typeof urlArg === 'object' && urlArg !== null && 'href' in urlArg) {
|
|
||||||
return (urlArg as URL).href;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFileURL = (url: string) => {
|
|
||||||
return url.startsWith('file://') || url.startsWith('data:');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isStaticAssetURL = (url: string) => {
|
|
||||||
return /\.(wasm|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)(\?|$)/i.test(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFirstPartyURL = (url: string) => {
|
|
||||||
return (
|
|
||||||
url.startsWith('/') ||
|
|
||||||
(process.env.EXPO_PUBLIC_BASE_URL && url.startsWith(process.env.EXPO_PUBLIC_BASE_URL))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSecondPartyURL = (url: string) => {
|
|
||||||
return url.startsWith('/_create/');
|
|
||||||
};
|
|
||||||
|
|
||||||
type Params = Parameters<typeof expoFetch>;
|
|
||||||
const fetchToWeb = async function fetchWithHeaders(...args: Params) {
|
|
||||||
const firstPartyURL = process.env.EXPO_PUBLIC_BASE_URL;
|
|
||||||
const secondPartyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
|
||||||
if (!firstPartyURL || !secondPartyURL) {
|
|
||||||
return expoFetch(...args);
|
|
||||||
}
|
|
||||||
const [input, init] = args;
|
|
||||||
const url = getURLFromArgs(input, init);
|
|
||||||
if (!url) {
|
|
||||||
return expoFetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFileURL(url) || isStaticAssetURL(url)) {
|
|
||||||
return originalFetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExternalFetch = !isFirstPartyURL(url);
|
|
||||||
// we should not add headers to requests that don't go to our own server
|
|
||||||
if (isExternalFetch) {
|
|
||||||
return expoFetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalInput = input;
|
|
||||||
const baseURL = isSecondPartyURL(url) ? secondPartyURL : firstPartyURL;
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
finalInput = input.startsWith('/') ? `${baseURL}${input}` : input;
|
|
||||||
} else {
|
|
||||||
return expoFetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
const initHeaders = init?.headers ?? {};
|
|
||||||
const finalHeaders = new Headers(initHeaders);
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
|
|
||||||
host: process.env.EXPO_PUBLIC_HOST,
|
|
||||||
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST,
|
|
||||||
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (value) {
|
|
||||||
finalHeaders.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await SecureStore.getItemAsync(authKey)
|
|
||||||
.then((auth) => {
|
|
||||||
return auth ? JSON.parse(auth) : null;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (auth) {
|
|
||||||
finalHeaders.set('authorization', `Bearer ${auth.jwt}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return expoFetch(finalInput, {
|
|
||||||
...init,
|
|
||||||
headers: finalHeaders,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default fetchToWeb;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<svg width={128} height={128} viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="895" height="895" rx="19" fill="#E9E7E7" />
|
|
||||||
<g stroke="#C0C0C0" stroke-width="1.00975">
|
|
||||||
<line x1="447.505" y1="-23" x2="447.505" y2="901" />
|
|
||||||
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505" />
|
|
||||||
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068" />
|
|
||||||
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504" />
|
|
||||||
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061" />
|
|
||||||
<line x1="282.495" y1="907" x2="282.495" y2="-30" />
|
|
||||||
<line x1="611.495" y1="907" x2="611.495" y2="-30" />
|
|
||||||
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061" />
|
|
||||||
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53" />
|
|
||||||
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612" />
|
|
||||||
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643" />
|
|
||||||
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772" />
|
|
||||||
<circle cx="447.5" cy="441.5" r="163.995" />
|
|
||||||
<circle cx="447.911" cy="447.911" r="237.407" />
|
|
||||||
<circle cx="448" cy="442" r="384.495" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +0,0 @@
|
|||||||
import updatedFetch from './fetch';
|
|
||||||
// @ts-expect-error -- updatedFetch wraps the native fetch with custom headers
|
|
||||||
global.fetch = updatedFetch;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Tabs } from 'expo-router';
|
|
||||||
import { Timer, History, Settings } from 'lucide-react-native';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#E5E7EB',
|
|
||||||
paddingTop: 4,
|
|
||||||
},
|
|
||||||
tabBarActiveTintColor: '#2563EB',
|
|
||||||
tabBarInactiveTintColor: '#6B7280',
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: 'Stopwatch',
|
|
||||||
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="history"
|
|
||||||
options={{
|
|
||||||
title: 'Geschiedenis',
|
|
||||||
tabBarIcon: ({ color }) => <History color={color} size={24} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="tasks"
|
|
||||||
options={{
|
|
||||||
title: 'Instellingen',
|
|
||||||
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
|
||||||
|
|
||||||
export default function HistoryScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
|
||||||
|
|
||||||
const { data: logs = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['logs'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
|
|
||||||
const supported = await Linking.canOpenURL(exportUrl);
|
|
||||||
if (supported) {
|
|
||||||
await Linking.openURL(exportUrl);
|
|
||||||
} else {
|
|
||||||
Alert.alert('Fout', 'Kan de export-URL niet openen');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const hrs = Math.floor(seconds / 3600);
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
if (hrs > 0) return `${hrs}h ${mins}m`;
|
|
||||||
if (mins > 0) return `${mins}m ${secs}s`;
|
|
||||||
return `${secs}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!fontsLoaded && !fontError) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingVertical: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#E5E7EB',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Geschiedenis
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleExport}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#EFF6FF',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 999,
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download color="#2563EB" size={16} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: '#2563EB',
|
|
||||||
fontWeight: '500',
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Exporteer CSV
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 20 }}>
|
|
||||||
{logs.length === 0 && !isLoading ? (
|
|
||||||
<View style={{ alignItems: 'center', marginTop: 100 }}>
|
|
||||||
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
|
|
||||||
Nog geen opgeslagen sessies.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
logs.map((log: any) => (
|
|
||||||
<View
|
|
||||||
key={log.id}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#111827',
|
|
||||||
marginBottom: 4,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.task_name}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
|
||||||
<Calendar color="#6B7280" size={12} />
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
|
|
||||||
>
|
|
||||||
{formatDate(log.start_time)} • {formatTime(log.start_time)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
||||||
{log.insole_type && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#F3F4F6',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#374151',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.insole_type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{log.pair_count != null && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#EFF6FF',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#BFDBFE',
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Layers color="#2563EB" size={12} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#2563EB',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock color="#111827" size={12} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDuration(log.duration_seconds)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,658 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
ScrollView,
|
|
||||||
TextInput,
|
|
||||||
Modal,
|
|
||||||
Animated,
|
|
||||||
Pressable,
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
||||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
|
|
||||||
|
|
||||||
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
|
||||||
type InsoleType = (typeof INSOLE_TYPES)[number];
|
|
||||||
|
|
||||||
export default function TimerScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// fontError: if fonts fail to load on Android we still render (no freeze)
|
|
||||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
|
||||||
|
|
||||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
|
|
||||||
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
|
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
|
||||||
const [startTime, setStartTime] = useState<Date | null>(null);
|
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
|
||||||
const [discardPending, setDiscardPending] = useState(false);
|
|
||||||
const [insoleCount, setInsoleCount] = useState(2);
|
|
||||||
const [insoleCountText, setInsoleCountText] = useState('2');
|
|
||||||
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
|
||||||
|
|
||||||
const { data: tasks = [] } = useQuery({
|
|
||||||
queryKey: ['tasks'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveLogMutation = useMutation({
|
|
||||||
mutationFn: async (log: any) => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/logs`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(log),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to save log');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRunning && !isPaused) {
|
|
||||||
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
|
|
||||||
} else {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
|
||||||
};
|
|
||||||
}, [isRunning, isPaused]);
|
|
||||||
|
|
||||||
const openPicker = () => {
|
|
||||||
setShowPicker(true);
|
|
||||||
Animated.timing(slideAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePicker = () => {
|
|
||||||
Animated.timing(slideAnim, {
|
|
||||||
toValue: SHEET_HEIGHT,
|
|
||||||
duration: 250,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start(() => setShowPicker(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = () => {
|
|
||||||
if (!activeTaskId) return;
|
|
||||||
setIsRunning(true);
|
|
||||||
setIsPaused(false);
|
|
||||||
setStartTime(new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePause = () => setIsPaused(true);
|
|
||||||
const handleResume = () => setIsPaused(false);
|
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
if (!activeTaskId || !startTime) return;
|
|
||||||
setIsRunning(false);
|
|
||||||
setIsPaused(false);
|
|
||||||
const endTime = new Date();
|
|
||||||
saveLogMutation.mutate({
|
|
||||||
task_id: activeTaskId,
|
|
||||||
start_time: startTime.toISOString(),
|
|
||||||
end_time: endTime.toISOString(),
|
|
||||||
duration_seconds: elapsedTime,
|
|
||||||
pair_count: insoleCount,
|
|
||||||
insole_type: insoleType,
|
|
||||||
});
|
|
||||||
setStartTime(null);
|
|
||||||
setElapsedTime(0);
|
|
||||||
setDiscardPending(false);
|
|
||||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDiscard = () => {
|
|
||||||
if (!discardPending) {
|
|
||||||
setDiscardPending(true);
|
|
||||||
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
|
|
||||||
} else {
|
|
||||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
|
||||||
setIsRunning(false);
|
|
||||||
setIsPaused(false);
|
|
||||||
setStartTime(null);
|
|
||||||
setElapsedTime(0);
|
|
||||||
setDiscardPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInsoleCountChange = (text: string) => {
|
|
||||||
setInsoleCountText(text);
|
|
||||||
const parsed = parseInt(text, 10);
|
|
||||||
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustInsoleCount = (delta: number) => {
|
|
||||||
const next = Math.max(1, insoleCount + delta);
|
|
||||||
setInsoleCount(next);
|
|
||||||
setInsoleCountText(String(next));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const hrs = Math.floor(seconds / 3600);
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
|
|
||||||
if (!fontsLoaded && !fontError) return null;
|
|
||||||
|
|
||||||
const regular = fontError ? undefined : 'Inter_400Regular';
|
|
||||||
const semibold = fontError ? undefined : 'Inter_600SemiBold';
|
|
||||||
|
|
||||||
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
|
|
||||||
const canStart = !!activeTaskId;
|
|
||||||
|
|
||||||
const filteredTasks = tasks.filter((t: any) =>
|
|
||||||
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 24 }}>
|
|
||||||
{/* 1. Type zool */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#6B7280',
|
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Type zool
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row' }}>
|
|
||||||
{INSOLE_TYPES.map((type, i) => {
|
|
||||||
const selected = insoleType === type;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={type}
|
|
||||||
onPress={() => {
|
|
||||||
if (isRunning) return;
|
|
||||||
setInsoleType(type);
|
|
||||||
setActiveTaskId(null);
|
|
||||||
}}
|
|
||||||
disabled={isRunning}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 14,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: selected ? '#2563EB' : '#E5E7EB',
|
|
||||||
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{type}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 2. Type handeling */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#6B7280',
|
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Type handeling
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => !isRunning && openPicker()}
|
|
||||||
disabled={isRunning}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderRadius: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
color: activeTaskId ? '#111827' : '#9CA3AF',
|
|
||||||
fontFamily: regular,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown color="#6B7280" size={20} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 3. Aantal zolen */}
|
|
||||||
<View style={{ marginBottom: 40 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#6B7280',
|
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Aantal zolen
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => adjustInsoleCount(-1)}
|
|
||||||
disabled={isRunning || insoleCount <= 1}
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 34,
|
|
||||||
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
|
|
||||||
fontFamily: semibold,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TextInput
|
|
||||||
value={insoleCountText}
|
|
||||||
onChangeText={handleInsoleCountChange}
|
|
||||||
keyboardType="number-pad"
|
|
||||||
editable={!isRunning}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: isRunning ? '#9CA3AF' : '#111827',
|
|
||||||
fontFamily: semibold,
|
|
||||||
paddingVertical: Platform.OS === 'android' ? 10 : 14,
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => adjustInsoleCount(1)}
|
|
||||||
disabled={isRunning}
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 34,
|
|
||||||
color: isRunning ? '#D1D5DB' : '#111827',
|
|
||||||
fontFamily: semibold,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 4. Stopwatch display */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (!isRunning && canStart) handleStart();
|
|
||||||
else if (isRunning) {
|
|
||||||
if (isPaused) handleResume();
|
|
||||||
else handlePause();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
activeOpacity={canStart || isRunning ? 0.75 : 1}
|
|
||||||
style={{
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 60,
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
borderRadius: 24,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
|
|
||||||
fontFamily: semibold,
|
|
||||||
letterSpacing: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(elapsedTime)}
|
|
||||||
</Text>
|
|
||||||
{isRunning ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: isPaused ? '#D97706' : '#2563EB',
|
|
||||||
fontWeight: '500',
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : canStart ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#EFF6FF',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: '#2563EB',
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
|
|
||||||
>
|
|
||||||
Tik om te starten
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 5. Knoppen */}
|
|
||||||
<View style={{ marginTop: 40 }}>
|
|
||||||
{!isRunning ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleStart}
|
|
||||||
disabled={!canStart}
|
|
||||||
style={{
|
|
||||||
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 18,
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play
|
|
||||||
fill={canStart ? 'white' : '#9CA3AF'}
|
|
||||||
color={canStart ? 'white' : '#9CA3AF'}
|
|
||||||
size={24}
|
|
||||||
style={{ marginRight: 8 }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: canStart ? 'white' : '#9CA3AF',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start Stopwatch
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleStop}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#DC2626',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 18,
|
|
||||||
borderRadius: 16,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
|
|
||||||
<Text
|
|
||||||
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
|
|
||||||
>
|
|
||||||
Stop & Opslaan
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleDiscard}
|
|
||||||
style={{
|
|
||||||
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 18,
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: discardPending ? '#ffffff' : '#6B7280',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: semibold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
|
|
||||||
<Modal
|
|
||||||
visible={showPicker}
|
|
||||||
transparent
|
|
||||||
animationType="none"
|
|
||||||
onRequestClose={closePicker}
|
|
||||||
statusBarTranslucent
|
|
||||||
>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
||||||
}}
|
|
||||||
onPress={closePicker}
|
|
||||||
/>
|
|
||||||
{/* Sheet */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: SHEET_HEIGHT,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderTopLeftRadius: 24,
|
|
||||||
borderTopRightRadius: 24,
|
|
||||||
transform: [{ translateY: slideAnim }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
|
|
||||||
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Sheet header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingBottom: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
|
|
||||||
>
|
|
||||||
Type handeling
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
|
|
||||||
Kies een handeling
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Task list */}
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#9CA3AF',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontFamily: regular,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
|
|
||||||
Instellingen.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map((task: any) => {
|
|
||||||
const selected = activeTaskId === task.id;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={task.id}
|
|
||||||
onPress={() => {
|
|
||||||
setActiveTaskId(task.id);
|
|
||||||
closePicker();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingVertical: 18,
|
|
||||||
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#F3F4F6',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
color: selected ? '#2563EB' : '#374151',
|
|
||||||
fontFamily: selected ? semibold : regular,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</Text>
|
|
||||||
{selected && <Check size={20} color="#2563EB" />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
TextInput,
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
|
||||||
|
|
||||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
|
||||||
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
|
||||||
type InsoleType = (typeof ALL_TYPES)[number];
|
|
||||||
|
|
||||||
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
|
|
||||||
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
|
|
||||||
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
|
|
||||||
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function TypeToggle({
|
|
||||||
type,
|
|
||||||
selected,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
type: InsoleType;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) {
|
|
||||||
const c = TYPE_COLORS[type];
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 7,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: selected ? c.border : '#E5E7EB',
|
|
||||||
backgroundColor: selected ? c.bg : '#F9FAFB',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selected && <Check size={13} color={c.text} />}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: selected ? c.text : '#9CA3AF',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{type}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: InsoleType }) {
|
|
||||||
const c = TYPE_COLORS[type];
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 3,
|
|
||||||
borderRadius: 999,
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: c.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
|
|
||||||
>
|
|
||||||
{type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TasksScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
|
||||||
|
|
||||||
const [newTaskName, setNewTaskName] = useState('');
|
|
||||||
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [editingName, setEditingName] = useState('');
|
|
||||||
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
|
|
||||||
|
|
||||||
const { data: tasks = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['tasks'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const addTaskMutation = useMutation({
|
|
||||||
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, insole_types }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to add task');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
||||||
setNewTaskName('');
|
|
||||||
setNewTaskTypes(['Kurk', 'Berk', '3D']);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateTaskMutation = useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
insole_types,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
insole_types: string[];
|
|
||||||
}) => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, insole_types }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to update task');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
||||||
setEditingId(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteTaskMutation = useMutation({
|
|
||||||
mutationFn: async (id: number) => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
|
|
||||||
if (!res.ok) throw new Error('Failed to delete task');
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleNewType = (type: InsoleType) => {
|
|
||||||
setNewTaskTypes((prev) =>
|
|
||||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEditType = (type: InsoleType) => {
|
|
||||||
setEditingTypes((prev) =>
|
|
||||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddTask = () => {
|
|
||||||
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
|
|
||||||
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartEdit = (task: any) => {
|
|
||||||
setEditingId(task.id);
|
|
||||||
setEditingName(task.name);
|
|
||||||
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmEdit = () => {
|
|
||||||
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
|
|
||||||
updateTaskMutation.mutate({
|
|
||||||
id: editingId,
|
|
||||||
name: editingName.trim(),
|
|
||||||
insole_types: editingTypes,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditingName('');
|
|
||||||
setEditingTypes([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (task: any) => {
|
|
||||||
Alert.alert(
|
|
||||||
'Taak verwijderen',
|
|
||||||
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
|
|
||||||
[
|
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Verwijderen',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => deleteTaskMutation.mutate(task.id),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!fontsLoaded && !fontError) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={{ flex: 1, backgroundColor: '#ffffff' }}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<View style={{ paddingTop: insets.top, flex: 1 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingVertical: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#E5E7EB',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Instellingen
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
|
|
||||||
>
|
|
||||||
Beheer handelingen per zooltype
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
{/* Add New Task */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
marginBottom: 28,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#6B7280',
|
|
||||||
marginBottom: 12,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nieuwe handeling toevoegen
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Name input */}
|
|
||||||
<TextInput
|
|
||||||
value={newTaskName}
|
|
||||||
onChangeText={setNewTaskName}
|
|
||||||
placeholder="Naam van de stap, bijv. Leerrand"
|
|
||||||
placeholderTextColor="#9CA3AF"
|
|
||||||
returnKeyType="done"
|
|
||||||
onSubmitEditing={handleAddTask}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 11,
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'Inter_400Regular',
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Insole type toggles */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#9CA3AF',
|
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Van toepassing op
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
|
||||||
{ALL_TYPES.map((type) => (
|
|
||||||
<TypeToggle
|
|
||||||
key={type}
|
|
||||||
type={type}
|
|
||||||
selected={newTaskTypes.includes(type)}
|
|
||||||
onPress={() => toggleNewType(type)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleAddTask}
|
|
||||||
disabled={
|
|
||||||
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addTaskMutation.isPending ? (
|
|
||||||
<ActivityIndicator color="white" size="small" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus
|
|
||||||
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stap toevoegen
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Task List */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#6B7280',
|
|
||||||
marginBottom: 12,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Huidige stappen ({tasks.length})
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
|
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
<View style={{ alignItems: 'center', marginTop: 40 }}>
|
|
||||||
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
|
|
||||||
Nog geen stappen. Voeg er een toe hierboven.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
tasks.map((task: any) => {
|
|
||||||
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
|
|
||||||
const isEditing = editingId === task.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={task.id}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
|
|
||||||
padding: 14,
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
{/* Edit name */}
|
|
||||||
<TextInput
|
|
||||||
value={editingName}
|
|
||||||
onChangeText={setEditingName}
|
|
||||||
autoFocus
|
|
||||||
returnKeyType="done"
|
|
||||||
onSubmitEditing={handleConfirmEdit}
|
|
||||||
style={{
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'Inter_400Regular',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#E5E7EB',
|
|
||||||
paddingBottom: 8,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Edit insole types */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#9CA3AF',
|
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Van toepassing op
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
|
||||||
{ALL_TYPES.map((type) => (
|
|
||||||
<TypeToggle
|
|
||||||
key={type}
|
|
||||||
type={type}
|
|
||||||
selected={editingTypes.includes(type)}
|
|
||||||
onPress={() => toggleEditType(type)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Confirm / Cancel */}
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleConfirmEdit}
|
|
||||||
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#DCFCE7',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{updateTaskMutation.isPending ? (
|
|
||||||
<ActivityIndicator color="#16A34A" size="small" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Check size={16} color="#16A34A" />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: '#16A34A',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Opslaan
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleCancelEdit}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#F3F4F6',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} color="#6B7280" />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: '#6B7280',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: 'Inter_600SemiBold',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuleren
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Task name + actions */}
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#374151',
|
|
||||||
fontFamily: 'Inter_400Regular',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleStartEdit(task)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#EFF6FF',
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil color="#2563EB" size={16} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleDelete(task)}
|
|
||||||
disabled={deleteTaskMutation.isPending}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#FEF2F2',
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 color="#DC2626" size={16} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Insole type badges */}
|
|
||||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
|
||||||
{types.map((type) => (
|
|
||||||
<TypeBadge key={type} type={type} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import {
|
|
||||||
type RelativePathString,
|
|
||||||
type SitemapType,
|
|
||||||
Stack,
|
|
||||||
useGlobalSearchParams,
|
|
||||||
useRouter,
|
|
||||||
useSitemap,
|
|
||||||
} from 'expo-router';
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
||||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
interface ParentSitemap {
|
|
||||||
expoPages?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
filePath: string;
|
|
||||||
cleanRoute?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotFoundScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useGlobalSearchParams();
|
|
||||||
const expoSitemap = useSitemap();
|
|
||||||
const [sitemap, setSitemap] = useState<SitemapType | ParentSitemap | null>(expoSitemap);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
||||||
const handler = (event: MessageEvent) => {
|
|
||||||
if (event.data.type === 'sandbox:sitemap') {
|
|
||||||
window.removeEventListener('message', handler);
|
|
||||||
setSitemap(event.data.sitemap);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'sandbox:sitemap',
|
|
||||||
},
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
window.addEventListener('message', handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isExpoSitemap = sitemap === expoSitemap;
|
|
||||||
const missingPath = params['not-found']?.[0] || '';
|
|
||||||
|
|
||||||
const availableRoutes = useMemo(() => {
|
|
||||||
return (
|
|
||||||
expoSitemap?.children?.filter(
|
|
||||||
(child) =>
|
|
||||||
child.href &&
|
|
||||||
child.contextKey !== './auth.jsx' &&
|
|
||||||
child.contextKey !== './auth.web.jsx' &&
|
|
||||||
child.contextKey !== './+not-found.tsx' &&
|
|
||||||
child.contextKey !== 'expo-router/build/views/Sitemap.js'
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [expoSitemap]);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
const hasTabsIndex = expoSitemap?.children?.some(
|
|
||||||
(child) =>
|
|
||||||
child.contextKey === './(tabs)/_layout.jsx' &&
|
|
||||||
child.children.some((child) => child.contextKey === './(tabs)/index.jsx')
|
|
||||||
);
|
|
||||||
if (isExpoSitemap) {
|
|
||||||
if (hasTabsIndex) {
|
|
||||||
router.replace('../(tabs)/index.jsx');
|
|
||||||
} else {
|
|
||||||
router.replace('../');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
router.replace('..');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigate = (url: string) => {
|
|
||||||
try {
|
|
||||||
if (url) {
|
|
||||||
router.push(url as RelativePathString);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Navigation error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreatePage = useCallback(() => {
|
|
||||||
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
type: 'sandbox:web:create',
|
|
||||||
path: missingPath,
|
|
||||||
view: 'mobile',
|
|
||||||
},
|
|
||||||
'*'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [missingPath]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen options={{ title: 'Page Not Found', headerShown: false }} />
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
|
|
||||||
<Ionicons name="arrow-back" size={18} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.pathContainer}>
|
|
||||||
<View style={styles.pathPrefix}>
|
|
||||||
<Text style={styles.pathPrefixText}>/</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.pathContent}>
|
|
||||||
<Text style={styles.pathText} numberOfLines={1}>
|
|
||||||
{missingPath}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.mainContent}>
|
|
||||||
<Text style={styles.title}>Uh-oh! This screen doesn't exist (yet).</Text>
|
|
||||||
|
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
Looks like "<Text style={styles.boldText}>/{missingPath}</Text>" isn't part of your
|
|
||||||
project. But no worries, you've got options!
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{typeof window !== 'undefined' && window.parent && window.parent !== window && (
|
|
||||||
<View style={styles.createPageContainer}>
|
|
||||||
<View style={styles.createPageContent}>
|
|
||||||
<View style={styles.createPageTextContainer}>
|
|
||||||
<Text style={styles.createPageTitle}>Build it from scratch</Text>
|
|
||||||
<Text style={styles.createPageDescription}>
|
|
||||||
Create a new screen to live at "/{missingPath}"
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.createPageButtonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleCreatePage()}
|
|
||||||
style={styles.createPageButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.createPageButtonText}>Create Screen</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={styles.routesLabel}>Check out all your project's routes here ↓</Text>
|
|
||||||
{!isExpoSitemap && sitemap ? (
|
|
||||||
<View style={styles.pagesContainer}>
|
|
||||||
<View style={styles.pagesListContainer}>
|
|
||||||
<Text style={styles.pagesLabel}>MOBILE</Text>
|
|
||||||
{((sitemap as ParentSitemap).expoPages || []).map((route, _index: number) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={route.id}
|
|
||||||
onPress={() => handleNavigate(route.cleanRoute || '')}
|
|
||||||
style={styles.pageButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.routeName}>{route.name}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.pagesContainer}>
|
|
||||||
<View style={styles.pagesListContainer}>
|
|
||||||
<Text style={styles.pagesLabel}>MOBILE</Text>
|
|
||||||
{(availableRoutes as SitemapType[]).map((route: SitemapType, _index: number) => {
|
|
||||||
const url =
|
|
||||||
typeof route.href === 'string' ? route.href : route.href?.pathname || '/';
|
|
||||||
|
|
||||||
if (url === '/(tabs)' && route.children) {
|
|
||||||
return route.children.map((childRoute: SitemapType) => {
|
|
||||||
const childUrl =
|
|
||||||
typeof childRoute.href === 'string'
|
|
||||||
? childRoute.href
|
|
||||||
: childRoute.href.pathname || '/';
|
|
||||||
const displayPath =
|
|
||||||
childUrl === '/(tabs)'
|
|
||||||
? 'Homepage'
|
|
||||||
: childUrl.replace(/^\//, '').replace(/^\(tabs\)\//, '');
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={childRoute.contextKey}
|
|
||||||
onPress={() => handleNavigate(childUrl)}
|
|
||||||
style={styles.pageButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.routeName}>{displayPath}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayPath = url === '/' ? 'Homepage' : url.replace(/^\//, '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={route.contextKey}
|
|
||||||
onPress={() => handleNavigate(url)}
|
|
||||||
style={styles.pageButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.routeName}>{displayPath}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 20,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
pathContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
height: 32,
|
|
||||||
width: 300,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5e5',
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
pathPrefix: {
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 5,
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRightWidth: 1,
|
|
||||||
borderRightColor: '#e5e5e5',
|
|
||||||
},
|
|
||||||
pathPrefixText: {
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
pathContent: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
pathText: {
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
mainContent: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: 40,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#111',
|
|
||||||
marginBottom: 16,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingBottom: 48,
|
|
||||||
color: '#666',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
boldText: {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
routesLabel: {
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 80,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
createPageContainer: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 800,
|
|
||||||
marginBottom: 40,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
createPageContent: {
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5e5',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 20,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
gap: 15,
|
|
||||||
},
|
|
||||||
createPageTextContainer: {
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
createPageTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#000',
|
|
||||||
fontWeight: '500',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
createPageDescription: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#666',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
createPageButtonContainer: {
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
createPageButton: {
|
|
||||||
backgroundColor: '#000',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 5,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
createPageButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
|
|
||||||
pagesContainer: {
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
pagesLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#ccc',
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
marginBottom: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
pagesListContainer: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 600,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
pageButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5e5',
|
|
||||||
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.05)',
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
routeName: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#111',
|
|
||||||
},
|
|
||||||
routePath: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#999',
|
|
||||||
},
|
|
||||||
|
|
||||||
routesContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingBottom: 20,
|
|
||||||
gap: 40,
|
|
||||||
},
|
|
||||||
routeCard: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 300,
|
|
||||||
minWidth: 150,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
routeButton: {
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: 1.4,
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e5e5e5',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
routePreview: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
},
|
|
||||||
routeLabel: {
|
|
||||||
paddingTop: 12,
|
|
||||||
color: '#666',
|
|
||||||
textAlign: 'left',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
return (
|
|
||||||
<NotFoundScreen />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file is customizable BUT — do not remove:
|
|
||||||
* • `<AuthModal />` render (shipped v2 auth modal; removing it breaks
|
|
||||||
* signin/signup since useAuth().signIn() only flips state, not render)
|
|
||||||
* • `useAuth().initiate()` + `isReady` gate (loads persisted session from
|
|
||||||
* SecureStore — removing causes user to appear signed-out on app launch)
|
|
||||||
*
|
|
||||||
* Safe to change: the Stack routes, QueryClient config, splash behavior, the
|
|
||||||
* wrapping providers, or to add nested providers around <Stack>.
|
|
||||||
*/
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { ErrorBoundary } from '@/__create/ErrorBoundary';
|
|
||||||
import { useAuth } from '@/utils/auth/useAuth';
|
|
||||||
import { AuthModal } from '@/utils/auth/useAuthModal';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { Stack } from 'expo-router';
|
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
||||||
void SplashScreen.preventAutoHideAsync();
|
|
||||||
|
|
||||||
const SPLASH_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
const { initiate, isReady } = useAuth();
|
|
||||||
const [timedOut, setTimedOut] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initiate();
|
|
||||||
}, [initiate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeout = setTimeout(() => setTimedOut(true), SPLASH_TIMEOUT_MS);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isReady || timedOut) {
|
|
||||||
void SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [isReady, timedOut]);
|
|
||||||
|
|
||||||
if (!isReady && !timedOut) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<Stack screenOptions={{ headerShown: false }} initialRouteName="(tabs)">
|
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
||||||
</Stack>
|
|
||||||
<AuthModal />
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Index() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import React, { useRef, useEffect, forwardRef } from 'react';
|
|
||||||
import { Platform, Keyboard, KeyboardAvoidingView, LayoutChangeEvent, ViewStyle, KeyboardEvent } from 'react-native';
|
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
||||||
|
|
||||||
interface Layout {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyboardAvoidingAnimatedViewProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
behavior?: 'padding' | 'height' | 'position';
|
|
||||||
keyboardVerticalOffset?: number;
|
|
||||||
style?: ViewStyle;
|
|
||||||
contentContainerStyle?: ViewStyle;
|
|
||||||
enabled?: boolean;
|
|
||||||
onLayout?: (event: LayoutChangeEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardAvoidingAnimatedView = forwardRef<Animated.View, KeyboardAvoidingAnimatedViewProps>((props, ref) => {
|
|
||||||
const {
|
|
||||||
children,
|
|
||||||
behavior = Platform.OS === 'ios' ? 'padding' : 'height',
|
|
||||||
keyboardVerticalOffset = 0,
|
|
||||||
style,
|
|
||||||
contentContainerStyle,
|
|
||||||
enabled = true,
|
|
||||||
onLayout,
|
|
||||||
...leftoverProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const animatedViewRef = useRef<Layout | null>(null); // ref to animated view in this polyfill
|
|
||||||
const initialHeight = useSharedValue(0); // original height of animated view before keyboard appears
|
|
||||||
const bottomHeight = useSharedValue(0); // whats going to be added to the bottom when keyboard appears
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
const onKeyboardShow = (event: KeyboardEvent) => {
|
|
||||||
const { duration, endCoordinates } = event;
|
|
||||||
const animatedView = animatedViewRef.current;
|
|
||||||
|
|
||||||
if (!animatedView) return;
|
|
||||||
|
|
||||||
let height = 0;
|
|
||||||
|
|
||||||
// calculate how much the view needs to move up
|
|
||||||
const keyboardY = endCoordinates.screenY - keyboardVerticalOffset;
|
|
||||||
height = Math.max(animatedView.y + animatedView.height - keyboardY, 0);
|
|
||||||
|
|
||||||
bottomHeight.value = withTiming(height, {
|
|
||||||
duration: duration > 10 ? duration : 300,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyboardHide = () => {
|
|
||||||
bottomHeight.value = withTiming(0, { duration: 300 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
||||||
|
|
||||||
const showSubscription = Keyboard.addListener(showEvent, onKeyboardShow);
|
|
||||||
const hideSubscription = Keyboard.addListener(hideEvent, onKeyboardHide);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
showSubscription.remove();
|
|
||||||
hideSubscription.remove();
|
|
||||||
};
|
|
||||||
}, [keyboardVerticalOffset, enabled, bottomHeight]);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (behavior === 'height') {
|
|
||||||
return {
|
|
||||||
height: initialHeight.value - bottomHeight.value,
|
|
||||||
flex: bottomHeight.value > 0 ? 0 : (null as never),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (behavior === 'padding') {
|
|
||||||
return {
|
|
||||||
paddingBottom: bottomHeight.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const positionAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
bottom: bottomHeight.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleLayout = (event: LayoutChangeEvent) => {
|
|
||||||
const layout = event.nativeEvent.layout;
|
|
||||||
animatedViewRef.current = layout;
|
|
||||||
|
|
||||||
// initial height before keybaord appears
|
|
||||||
if (initialHeight.value === 0) {
|
|
||||||
initialHeight.value = layout.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onLayout) {
|
|
||||||
onLayout(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (behavior === 'position') {
|
|
||||||
return (
|
|
||||||
<Animated.View style={[contentContainerStyle, positionAnimatedStyle]}>
|
|
||||||
{children}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// render children if padding or height
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
// for web, default to unused keyboard avoiding view
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={behavior}
|
|
||||||
style={style}
|
|
||||||
contentContainerStyle={contentContainerStyle}
|
|
||||||
{...leftoverProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
ref={ref}
|
|
||||||
style={[style, animatedStyle]}
|
|
||||||
onLayout={handleLayout}
|
|
||||||
{...leftoverProps}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
KeyboardAvoidingAnimatedView.displayName = 'KeyboardAvoidingAnimatedView';
|
|
||||||
|
|
||||||
export default KeyboardAvoidingAnimatedView;
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 auth WebView. Handles both native (iOS/Android WebView +
|
|
||||||
* onShouldStartLoadWithRequest → fetch /api/auth/token → setAuth) and web
|
|
||||||
* (iframe + window.addEventListener('message') listening for AUTH_SUCCESS
|
|
||||||
* postMessage from /api/auth/expo-web-success). BOTH code paths are
|
|
||||||
* load-bearing; do NOT delete the web branch because you're only testing
|
|
||||||
* native, and vice versa. The postMessage contract { type, jwt, user } must
|
|
||||||
* stay in sync with /api/auth/expo-web-success/route.ts.
|
|
||||||
*/
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import { WebView } from 'react-native-webview';
|
|
||||||
import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
|
|
||||||
import { useAuthStore } from './store';
|
|
||||||
|
|
||||||
const callbackUrl = '/api/auth/token';
|
|
||||||
const callbackQueryString = `callbackUrl=${callbackUrl}`;
|
|
||||||
|
|
||||||
// Normalize the expected origin once. `new URL(...).origin` strips trailing
|
|
||||||
// slashes, paths, and query — so a stray slash in EXPO_PUBLIC_PROXY_BASE_URL
|
|
||||||
// no longer silently drops every postMessage from the auth iframe.
|
|
||||||
const allowedOrigin = (() => {
|
|
||||||
const raw = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
return new URL(raw).origin;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
interface AuthWebViewProps {
|
|
||||||
mode: 'signup' | 'signin';
|
|
||||||
proxyURL: string;
|
|
||||||
baseURL: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthMessageData {
|
|
||||||
type: 'AUTH_SUCCESS' | 'AUTH_ERROR';
|
|
||||||
jwt?: string;
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This renders a WebView for authentication and handles both web and native platforms.
|
|
||||||
*/
|
|
||||||
export const AuthWebView = ({ mode, proxyURL, baseURL }: AuthWebViewProps) => {
|
|
||||||
const [currentURI, setURI] = useState(`${baseURL}/account/${mode}?${callbackQueryString}`);
|
|
||||||
const { auth, setAuth, isReady } = useAuthStore();
|
|
||||||
const isAuthenticated = isReady ? !!auth : null;
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isAuthenticated) {
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}, [isAuthenticated]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setURI(`${baseURL}/account/${mode}?${callbackQueryString}`);
|
|
||||||
}, [mode, baseURL, isAuthenticated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined' || !window.addEventListener) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const handleMessage = (event: MessageEvent<AuthMessageData>) => {
|
|
||||||
// Verify the origin for security. Compare normalized origins so a
|
|
||||||
// trailing slash or path in EXPO_PUBLIC_PROXY_BASE_URL doesn't drop
|
|
||||||
// legitimate messages. Surface drops via console.warn instead of
|
|
||||||
// silently swallowing them.
|
|
||||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
|
||||||
console.warn(
|
|
||||||
`AuthWebView: dropping message from unexpected origin ${event.origin}; expected ${allowedOrigin}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.data.type === 'AUTH_SUCCESS') {
|
|
||||||
setAuth({
|
|
||||||
jwt: event.data.jwt!,
|
|
||||||
user: event.data.user!,
|
|
||||||
});
|
|
||||||
} else if (event.data.type === 'AUTH_ERROR') {
|
|
||||||
console.error('Auth error:', event.data.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handleMessage);
|
|
||||||
};
|
|
||||||
}, [setAuth]);
|
|
||||||
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
const handleIframeError = () => {
|
|
||||||
console.error('Failed to load auth iframe');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
title="Authentication"
|
|
||||||
src={`${proxyURL}/account/${mode}?callbackUrl=/api/auth/expo-web-success`}
|
|
||||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
|
||||||
onError={handleIframeError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<WebView
|
|
||||||
sharedCookiesEnabled
|
|
||||||
source={{
|
|
||||||
uri: currentURI,
|
|
||||||
}}
|
|
||||||
headers={{
|
|
||||||
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID!,
|
|
||||||
host: process.env.EXPO_PUBLIC_HOST!,
|
|
||||||
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST!,
|
|
||||||
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST!,
|
|
||||||
}}
|
|
||||||
onShouldStartLoadWithRequest={(request: WebViewNavigation) => {
|
|
||||||
if (request.url === `${baseURL}${callbackUrl}`) {
|
|
||||||
fetch(request.url)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
setAuth({ jwt: data.jwt, user: data.user });
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (request.url === currentURI) return true;
|
|
||||||
|
|
||||||
// Add query string properly by checking if URL already has parameters
|
|
||||||
const hasParams = request.url.includes('?');
|
|
||||||
const separator = hasParams ? '&' : '?';
|
|
||||||
const newURL = request.url.replaceAll(proxyURL, baseURL);
|
|
||||||
if (newURL.endsWith(callbackUrl)) {
|
|
||||||
setURI(newURL);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setURI(`${newURL}${separator}${callbackQueryString}`);
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 auth helpers. `authFetch` auto-adds Authorization: Bearer <jwt>
|
|
||||||
* when a session exists — use it instead of bare fetch() for calls to the
|
|
||||||
* web app's API routes. The web server's better-auth bearer() plugin
|
|
||||||
* validates these headers. DO NOT reimplement these helpers in user code.
|
|
||||||
*/
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useAuthStore } from './store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the current session (jwt + user) synchronously from the auth store.
|
|
||||||
* Returns null if the user is not authenticated.
|
|
||||||
*/
|
|
||||||
export const getSession = () => useAuthStore.getState().auth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the current session's JWT for use in an Authorization header.
|
|
||||||
* Returns null if the user is not authenticated.
|
|
||||||
*/
|
|
||||||
export const getJwt = () => useAuthStore.getState().auth?.jwt ?? null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop-in replacement for fetch() that automatically adds the
|
|
||||||
* `Authorization: Bearer <jwt>` header when the user is signed in. Use this
|
|
||||||
* for calls from the mobile app to the web app's API routes — the web server
|
|
||||||
* uses better-auth's `bearer()` plugin to authenticate these requests.
|
|
||||||
*
|
|
||||||
* Existing Authorization headers on the caller's `init.headers` are preserved.
|
|
||||||
*/
|
|
||||||
export const authFetch: typeof fetch = (input, init) => {
|
|
||||||
const jwt = getJwt();
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
if (jwt && !headers.has('Authorization')) {
|
|
||||||
headers.set('Authorization', `Bearer ${jwt}`);
|
|
||||||
}
|
|
||||||
return fetch(input, { ...init, headers });
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 auth barrel. Keeps the public import surface stable so user
|
|
||||||
* code can `import { useAuth, useUser, useAuthModal, authFetch } from '@/utils/auth'`.
|
|
||||||
* DO NOT remove or rename these re-exports — downstream code breaks.
|
|
||||||
*/
|
|
||||||
import { useAuth, useRequireAuth } from './useAuth';
|
|
||||||
import { useUser } from './useUser';
|
|
||||||
import { useAuthModal } from './store';
|
|
||||||
|
|
||||||
export { useAuth, useRequireAuth, useUser, useAuthModal };
|
|
||||||
export { authFetch, getJwt, getSession } from './getSession';
|
|
||||||
export default useAuth;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 zustand stores for auth session (persisted to SecureStore) and
|
|
||||||
* auth modal open/close state. `useAuth`, `useAuthModal` component, and
|
|
||||||
* `AuthWebView` all read from these stores — renaming fields or changing
|
|
||||||
* shape breaks all three. DO NOT replace with Context, DO NOT merge into a
|
|
||||||
* single store.
|
|
||||||
*/
|
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
export const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicit Keychain options used on every SecureStore call in the auth flow.
|
|
||||||
*
|
|
||||||
* - keychainService: pinned to a stable name so reads and writes always hit
|
|
||||||
* the same partition. Without this, SecureStore derives a service name from
|
|
||||||
* the bundle that can drift between Classic and EAS builds, causing reads
|
|
||||||
* to miss writes from a previous build.
|
|
||||||
* - keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY allows the auth
|
|
||||||
* token to be read on every cold launch after the device has been unlocked
|
|
||||||
* once since boot. The default (WHEN_UNLOCKED) refuses access during the
|
|
||||||
* first-unlock window, which is the most common TestFlight failure mode.
|
|
||||||
* - requireAuthentication: false keeps SecureStore on its non-biometric code
|
|
||||||
* path, so it never reads NSFaceIDUsageDescription or constructs a
|
|
||||||
* biometry-current-set access control object — both of which can throw
|
|
||||||
* NSException and trip iOS 26's unhandled async-void TurboModule rethrow.
|
|
||||||
*/
|
|
||||||
export const secureStoreOptions: SecureStore.SecureStoreOptions = {
|
|
||||||
keychainService: 'anything-auth',
|
|
||||||
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
||||||
requireAuthentication: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Auth {
|
|
||||||
jwt: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
isReady: boolean;
|
|
||||||
auth: Auth | null;
|
|
||||||
setAuth: (auth: Auth | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This store manages the authentication state of the application.
|
|
||||||
*/
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
|
||||||
isReady: false,
|
|
||||||
auth: null,
|
|
||||||
setAuth: (auth) => {
|
|
||||||
if (auth) {
|
|
||||||
SecureStore.setItemAsync(
|
|
||||||
authKey,
|
|
||||||
JSON.stringify(auth),
|
|
||||||
secureStoreOptions,
|
|
||||||
).catch(() => {
|
|
||||||
// Swallow Keychain write errors — the app remains in-memory authed
|
|
||||||
// for this session and the next launch will re-auth via the WebView.
|
|
||||||
// Throwing here would propagate into the unhandled-rejection /
|
|
||||||
// TurboModule rethrow path and crash on iOS 26.x.
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
SecureStore.deleteItemAsync(authKey, secureStoreOptions).catch(() => {});
|
|
||||||
}
|
|
||||||
set({ auth });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface AuthModalState {
|
|
||||||
isOpen: boolean;
|
|
||||||
mode: 'signup' | 'signin';
|
|
||||||
open: (options?: { mode?: 'signup' | 'signin' }) => void;
|
|
||||||
close: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This store manages the state of the authentication modal.
|
|
||||||
*/
|
|
||||||
export const useAuthModal = create<AuthModalState>((set) => ({
|
|
||||||
isOpen: false,
|
|
||||||
mode: 'signup',
|
|
||||||
open: (options) => set({ isOpen: true, mode: options?.mode || 'signup' }),
|
|
||||||
close: () => set({ isOpen: false }),
|
|
||||||
}));
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 mobile auth hook. `useAuth()` is the public surface for
|
|
||||||
* user apps — `{ signIn, signUp, signOut, auth, isAuthenticated, isReady }`.
|
|
||||||
* These names are documented in the v2 auth prompt; user code imports them
|
|
||||||
* directly. DO NOT rename them, DO NOT remove `initiate()` (it loads the
|
|
||||||
* persisted session from SecureStore), and DO NOT add side effects that run
|
|
||||||
* before isReady flips true.
|
|
||||||
*/
|
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { useCallback, useEffect } from 'react';
|
|
||||||
import { authKey, type Auth, secureStoreOptions, useAuthModal, useAuthStore } from './store';
|
|
||||||
|
|
||||||
interface UseAuthReturn {
|
|
||||||
isReady: boolean;
|
|
||||||
isAuthenticated: boolean | null;
|
|
||||||
signIn: () => void;
|
|
||||||
signOut: () => void;
|
|
||||||
signUp: () => void;
|
|
||||||
auth: Auth | null;
|
|
||||||
setAuth: (auth: Auth | null) => void;
|
|
||||||
initiate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This hook provides authentication functionality.
|
|
||||||
* It may be easier to use the `useAuthModal` or `useRequireAuth` hooks
|
|
||||||
* instead as those will also handle showing authentication to the user
|
|
||||||
* directly.
|
|
||||||
*/
|
|
||||||
export const useAuth = (): UseAuthReturn => {
|
|
||||||
const { isReady, auth, setAuth } = useAuthStore();
|
|
||||||
const { isOpen: _isOpen, close, open } = useAuthModal();
|
|
||||||
|
|
||||||
const initiate = useCallback(() => {
|
|
||||||
// The auth state machine must always reach a terminal state. SecureStore
|
|
||||||
// can throw or hang in TestFlight release builds (Keychain access denied,
|
|
||||||
// missing keychain-access-groups entitlement after EAS migration, locked
|
|
||||||
// device first-unlock state, or iOS 26 TurboModule rethrow). Without a
|
|
||||||
// catch the unhandled rejection leaves isReady=false forever and the
|
|
||||||
// RootLayout renders null — the user sees a blank screen indefinitely.
|
|
||||||
Promise.race<string | null>([
|
|
||||||
SecureStore.getItemAsync(authKey, secureStoreOptions),
|
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 3000)),
|
|
||||||
])
|
|
||||||
.then((authString) => {
|
|
||||||
useAuthStore.setState({
|
|
||||||
auth: authString ? (JSON.parse(authString) as Auth) : null,
|
|
||||||
isReady: true,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
useAuthStore.setState({ auth: null, isReady: true });
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {}, []);
|
|
||||||
|
|
||||||
const signIn = useCallback(() => {
|
|
||||||
open({ mode: 'signin' });
|
|
||||||
}, [open]);
|
|
||||||
const signUp = useCallback(() => {
|
|
||||||
open({ mode: 'signup' });
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const signOut = useCallback(() => {
|
|
||||||
setAuth(null);
|
|
||||||
close();
|
|
||||||
}, [close, setAuth]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isReady,
|
|
||||||
isAuthenticated: isReady ? !!auth : null,
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
signUp,
|
|
||||||
auth,
|
|
||||||
setAuth,
|
|
||||||
initiate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseRequireAuthOptions {
|
|
||||||
mode?: 'signup' | 'signin';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRequireAuth = (options?: UseRequireAuthOptions): UseAuthReturn => {
|
|
||||||
const authReturn = useAuth();
|
|
||||||
const { open } = useAuthModal();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authReturn.isAuthenticated && authReturn.isReady) {
|
|
||||||
open({ mode: options?.mode });
|
|
||||||
}
|
|
||||||
}, [authReturn.isAuthenticated, open, options?.mode, authReturn.isReady]);
|
|
||||||
|
|
||||||
return authReturn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useAuth;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* Shipped v2 <AuthModal /> — the modal that wraps the AuthWebView. It's
|
|
||||||
* already mounted in app/_layout.tsx; DO NOT mount it again. The env-var
|
|
||||||
* preflight (returns a "not configured" modal when EXPO_PUBLIC_BASE_URL or
|
|
||||||
* EXPO_PUBLIC_PROXY_BASE_URL is missing) is intentional — removing it turns
|
|
||||||
* env-var misconfig into a silent "nothing happens" bug. The named export of
|
|
||||||
* useAuthModal at the top is also load-bearing (user code imports it from
|
|
||||||
* this file, not just from ./store).
|
|
||||||
*/
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Modal, Text, View } from 'react-native';
|
|
||||||
import { AuthWebView } from './AuthWebView';
|
|
||||||
import { useAuthModal, useAuthStore } from './store';
|
|
||||||
|
|
||||||
export { useAuthModal } from './store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This component renders a modal for authentication purposes.
|
|
||||||
* To show it programmatically, you should either use the `useRequireAuth` hook or the `useAuthModal` hook.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```js
|
|
||||||
* import { useAuthModal } from '@/utils/useAuthModal';
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { open } = useAuthModal();
|
|
||||||
* return <Button title="Login" onPress={() => open({ mode: 'signin' })} />;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```js
|
|
||||||
* import { useRequireAuth } from '@/utils/auth';
|
|
||||||
* function MyComponent() {
|
|
||||||
* // automatically opens the auth modal if the user is not authenticated
|
|
||||||
* useRequireAuth();
|
|
||||||
* return <Text>Protected Content</Text>;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const AuthModal = () => {
|
|
||||||
const { auth } = useAuthStore();
|
|
||||||
const { isOpen, mode } = useAuthModal();
|
|
||||||
|
|
||||||
const proxyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
|
||||||
const baseURL = process.env.EXPO_PUBLIC_BASE_URL;
|
|
||||||
if (!proxyURL || !baseURL) {
|
|
||||||
const missing = [
|
|
||||||
!proxyURL && 'EXPO_PUBLIC_PROXY_BASE_URL',
|
|
||||||
!baseURL && 'EXPO_PUBLIC_BASE_URL',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
console.error(
|
|
||||||
`AuthModal: missing required env var(s): ${missing}. Auth cannot open.`
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={isOpen && !auth}
|
|
||||||
animationType="slide"
|
|
||||||
presentationStyle="pageSheet"
|
|
||||||
>
|
|
||||||
<View className="flex-1 items-center justify-center bg-white p-[24px]">
|
|
||||||
<Text className="mb-[8px] text-[18px] font-semibold">
|
|
||||||
Auth is not configured
|
|
||||||
</Text>
|
|
||||||
<Text className="text-center text-[14px] text-gray-600">
|
|
||||||
Missing environment variable{missing.includes(',') ? 's' : ''}:{' '}
|
|
||||||
{missing}. Set {missing.includes(',') ? 'them' : 'it'} in your .env
|
|
||||||
and restart the app.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={isOpen && !auth} animationType="slide" presentationStyle='pageSheet'>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AuthWebView
|
|
||||||
mode={mode}
|
|
||||||
proxyURL={proxyURL}
|
|
||||||
baseURL={baseURL}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useAuthModal;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
|
||||||
*
|
|
||||||
* V1-compatible mobile user hook. Migrated apps commonly import
|
|
||||||
* `@/utils/auth/useUser` and expect `{ user, data, loading, refetch }`.
|
|
||||||
* Keep this surface stable; the V2 auth state still comes from `useAuth()`.
|
|
||||||
*/
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useAuth } from './useAuth';
|
|
||||||
|
|
||||||
export const useUser = () => {
|
|
||||||
const { auth, isReady } = useAuth();
|
|
||||||
const user = auth?.user ?? null;
|
|
||||||
const refetch = useCallback(async () => user, [user]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
data: user,
|
|
||||||
loading: !isReady,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useUser;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const mockConfigure = jest.fn();
|
|
||||||
export const mockSetLogLevel = jest.fn();
|
|
||||||
export const mockGetOfferings = jest.fn();
|
|
||||||
export const mockPurchasePackage = jest.fn();
|
|
||||||
export const mockRestorePurchases = jest.fn();
|
|
||||||
|
|
||||||
const Purchases = {
|
|
||||||
configure: (...args: any[]) => mockConfigure(...args),
|
|
||||||
setLogLevel: (...args: any[]) => mockSetLogLevel(...args),
|
|
||||||
getOfferings: (...args: any[]) => mockGetOfferings(...args),
|
|
||||||
purchasePackage: (...args: any[]) => mockPurchasePackage(...args),
|
|
||||||
restorePurchases: (...args: any[]) => mockRestorePurchases(...args),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Purchases;
|
|
||||||
export const LOG_LEVEL = { INFO: 'INFO' };
|
|
||||||
export const PRODUCT_CATEGORY = { SUBSCRIPTION: 'SUBSCRIPTION' };
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export const Platform = {
|
|
||||||
select: (opts: Record<string, any>) => opts.ios,
|
|
||||||
OS: 'ios',
|
|
||||||
};
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the useInAppPurchase logic functions.
|
|
||||||
*
|
|
||||||
* These verify:
|
|
||||||
* 1. Original behavior from the inlined documentation.ts code is preserved
|
|
||||||
* (SDK configuration, offerings loading, subscription status, purchasing)
|
|
||||||
* 2. Bug fixes over the old inline code:
|
|
||||||
* - Offerings are awaited before isReady is set (was fire-and-forget)
|
|
||||||
* - Retry logic handles TestFlight cold-start failures
|
|
||||||
* - getAvailablePackages returns [] instead of throwing on null offerings
|
|
||||||
* - Purchases.configure() is only called once
|
|
||||||
* - restorePurchases is included (App Store Guideline 3.1.1)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
mockConfigure,
|
|
||||||
mockSetLogLevel,
|
|
||||||
mockGetOfferings,
|
|
||||||
mockPurchasePackage,
|
|
||||||
mockRestorePurchases,
|
|
||||||
} from './__mocks__/react-native-purchases';
|
|
||||||
import {
|
|
||||||
getRevenueCatAPIKey,
|
|
||||||
loadOfferings,
|
|
||||||
fetchSubscriptionStatus,
|
|
||||||
initiatePurchases,
|
|
||||||
getAvailablePackagesFromOfferings,
|
|
||||||
getSubscriptionsFromOfferings,
|
|
||||||
executePurchase,
|
|
||||||
executeRestore,
|
|
||||||
} from '../useInAppPurchase';
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
const makeOfferings = (hasCurrent = true) => ({
|
|
||||||
current: hasCurrent
|
|
||||||
? {
|
|
||||||
availablePackages: [
|
|
||||||
{
|
|
||||||
identifier: 'lifetime',
|
|
||||||
product: {
|
|
||||||
priceString: '$1.99',
|
|
||||||
productCategory: 'SUBSCRIPTION',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
identifier: 'credits',
|
|
||||||
product: {
|
|
||||||
priceString: '$4.99',
|
|
||||||
productCategory: 'NON_SUBSCRIPTION',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeStoreCallbacks() {
|
|
||||||
return {
|
|
||||||
setOfferings: jest.fn(),
|
|
||||||
setIsSubscribed: jest.fn(),
|
|
||||||
setIsReady: jest.fn(),
|
|
||||||
isConfigured: { current: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.useFakeTimers();
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV = 'PRODUCTION';
|
|
||||||
process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY = 'pk_ios_test';
|
|
||||||
process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY = 'pk_android_test';
|
|
||||||
process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY = 'pk_test_test';
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ hasAccess: false }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Tests ---
|
|
||||||
|
|
||||||
describe('getRevenueCatAPIKey', () => {
|
|
||||||
test('returns iOS key in production', () => {
|
|
||||||
expect(getRevenueCatAPIKey()).toBe('pk_ios_test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns test store key in DEVELOPMENT', () => {
|
|
||||||
process.env.EXPO_PUBLIC_CREATE_ENV = 'DEVELOPMENT';
|
|
||||||
expect(getRevenueCatAPIKey()).toBe('pk_test_test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns undefined when no keys are set', () => {
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
|
||||||
expect(getRevenueCatAPIKey()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initiatePurchases', () => {
|
|
||||||
test('configures SDK with correct API key', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(mockConfigure).toHaveBeenCalledWith({ apiKey: 'pk_ios_test' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets log level to INFO', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(mockSetLogLevel).toHaveBeenCalledWith('INFO');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loads offerings and fetches subscription status in parallel', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(cbs.setOfferings).toHaveBeenCalledWith(makeOfferings());
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
'/api/revenue-cat/get-subscription-status',
|
|
||||||
{ method: 'POST' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets isReady true after completion', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not configure when no API key is available', async () => {
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
|
|
||||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(mockConfigure).not.toHaveBeenCalled();
|
|
||||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: isReady only set AFTER offerings have loaded (was fire-and-forget)', async () => {
|
|
||||||
let resolveOfferings!: Function;
|
|
||||||
mockGetOfferings.mockImplementation(
|
|
||||||
() => new Promise((resolve) => { resolveOfferings = () => resolve(makeOfferings()); })
|
|
||||||
);
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
const promise = initiatePurchases(cbs);
|
|
||||||
|
|
||||||
// Before offerings resolve, setIsReady should NOT have been called
|
|
||||||
expect(cbs.setIsReady).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
resolveOfferings();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
// Now it should be called
|
|
||||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
|
||||||
expect(cbs.setOfferings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: configure() only called once even if initiate() called multiple times', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
|
||||||
const cbs = makeStoreCallbacks();
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
await initiatePurchases(cbs);
|
|
||||||
expect(mockConfigure).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadOfferings', () => {
|
|
||||||
test('stores offerings on success', async () => {
|
|
||||||
const offerings = makeOfferings();
|
|
||||||
mockGetOfferings.mockResolvedValue(offerings);
|
|
||||||
const setOfferings = jest.fn();
|
|
||||||
await loadOfferings(setOfferings);
|
|
||||||
expect(setOfferings).toHaveBeenCalledWith(offerings);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: retries up to 3 times on failure', async () => {
|
|
||||||
mockGetOfferings
|
|
||||||
.mockRejectedValueOnce(new Error('cold start'))
|
|
||||||
.mockRejectedValueOnce(new Error('still loading'))
|
|
||||||
.mockResolvedValueOnce(makeOfferings());
|
|
||||||
const setOfferings = jest.fn();
|
|
||||||
|
|
||||||
const promise = loadOfferings(setOfferings);
|
|
||||||
await jest.advanceTimersByTimeAsync(1500);
|
|
||||||
await jest.advanceTimersByTimeAsync(1500);
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
|
||||||
expect(setOfferings).toHaveBeenCalledWith(makeOfferings());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: retries when offerings load but current is null', async () => {
|
|
||||||
mockGetOfferings
|
|
||||||
.mockResolvedValueOnce(makeOfferings(false))
|
|
||||||
.mockResolvedValueOnce(makeOfferings(false))
|
|
||||||
.mockResolvedValueOnce(makeOfferings(true));
|
|
||||||
const setOfferings = jest.fn();
|
|
||||||
|
|
||||||
const promise = loadOfferings(setOfferings);
|
|
||||||
await jest.advanceTimersByTimeAsync(1500);
|
|
||||||
await jest.advanceTimersByTimeAsync(1500);
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
|
||||||
expect(setOfferings).toHaveBeenCalledWith(makeOfferings(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: does not call setOfferings when all retries fail', async () => {
|
|
||||||
mockGetOfferings.mockRejectedValue(new Error('permanent failure'));
|
|
||||||
const setOfferings = jest.fn();
|
|
||||||
|
|
||||||
const promise = loadOfferings(setOfferings);
|
|
||||||
await jest.advanceTimersByTimeAsync(3000);
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
|
||||||
expect(setOfferings).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stops retrying early when current offering is found', async () => {
|
|
||||||
mockGetOfferings.mockResolvedValue(makeOfferings(true));
|
|
||||||
const setOfferings = jest.fn();
|
|
||||||
|
|
||||||
await loadOfferings(setOfferings);
|
|
||||||
|
|
||||||
expect(mockGetOfferings).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setOfferings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchSubscriptionStatus', () => {
|
|
||||||
test('sets subscribed true when server returns hasAccess true', async () => {
|
|
||||||
(global.fetch as jest.Mock).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ hasAccess: true }),
|
|
||||||
});
|
|
||||||
const setIsSubscribed = jest.fn();
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
expect(setIsSubscribed).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets subscribed false when server returns hasAccess false', async () => {
|
|
||||||
const setIsSubscribed = jest.fn();
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets subscribed false on network error', async () => {
|
|
||||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('network'));
|
|
||||||
const setIsSubscribed = jest.fn();
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets subscribed false on non-ok response', async () => {
|
|
||||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false });
|
|
||||||
const setIsSubscribed = jest.fn();
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAvailablePackagesFromOfferings', () => {
|
|
||||||
test('returns packages from current offering', () => {
|
|
||||||
const offerings = makeOfferings();
|
|
||||||
const packages = getAvailablePackagesFromOfferings(offerings);
|
|
||||||
expect(packages).toHaveLength(2);
|
|
||||||
expect(packages[0].identifier).toBe('lifetime');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
|
|
||||||
expect(() => getAvailablePackagesFromOfferings(null)).not.toThrow();
|
|
||||||
expect(getAvailablePackagesFromOfferings(null)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: returns [] when current is null (old code threw)', () => {
|
|
||||||
expect(() => getAvailablePackagesFromOfferings(makeOfferings(false))).not.toThrow();
|
|
||||||
expect(getAvailablePackagesFromOfferings(makeOfferings(false))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSubscriptionsFromOfferings', () => {
|
|
||||||
test('filters by SUBSCRIPTION category', () => {
|
|
||||||
const subs = getSubscriptionsFromOfferings(makeOfferings());
|
|
||||||
expect(subs).toHaveLength(1);
|
|
||||||
expect(subs[0].identifier).toBe('lifetime');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
|
|
||||||
expect(() => getSubscriptionsFromOfferings(null)).not.toThrow();
|
|
||||||
expect(getSubscriptionsFromOfferings(null)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('executePurchase', () => {
|
|
||||||
test('calls SDK and returns success with customerInfo', async () => {
|
|
||||||
const customerInfo = { entitlements: { active: { pro: {} } } };
|
|
||||||
mockPurchasePackage.mockResolvedValue({ customerInfo });
|
|
||||||
const result = await executePurchase({
|
|
||||||
pkg: { identifier: 'test' },
|
|
||||||
setIsSubscribed: jest.fn(),
|
|
||||||
});
|
|
||||||
expect(mockPurchasePackage).toHaveBeenCalledWith({ identifier: 'test' });
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.customerInfo).toBe(customerInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns cancelled when user cancels', async () => {
|
|
||||||
mockPurchasePackage.mockRejectedValue({ userCancelled: true });
|
|
||||||
const result = await executePurchase({
|
|
||||||
pkg: { identifier: 'test' },
|
|
||||||
setIsSubscribed: jest.fn(),
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, cancelled: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns failure on error', async () => {
|
|
||||||
mockPurchasePackage.mockRejectedValue(new Error('payment failed'));
|
|
||||||
const result = await executePurchase({
|
|
||||||
pkg: { identifier: 'test' },
|
|
||||||
setIsSubscribed: jest.fn(),
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, cancelled: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshes subscription status after purchase', async () => {
|
|
||||||
mockPurchasePackage.mockResolvedValue({
|
|
||||||
customerInfo: { entitlements: { active: {} } },
|
|
||||||
});
|
|
||||||
const setIsSubscribed = jest.fn();
|
|
||||||
await executePurchase({ pkg: { identifier: 'test' }, setIsSubscribed });
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
'/api/revenue-cat/get-subscription-status',
|
|
||||||
{ method: 'POST' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('executeRestore', () => {
|
|
||||||
test('BUG FIX: restorePurchases works (App Store Guideline 3.1.1)', async () => {
|
|
||||||
const customerInfo = { entitlements: { active: { premium: {} } } };
|
|
||||||
mockRestorePurchases.mockResolvedValue(customerInfo);
|
|
||||||
const result = await executeRestore(jest.fn());
|
|
||||||
expect(mockRestorePurchases).toHaveBeenCalled();
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.customerInfo).toBe(customerInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns success false when no active entitlements', async () => {
|
|
||||||
mockRestorePurchases.mockResolvedValue({ entitlements: { active: {} } });
|
|
||||||
const result = await executeRestore(jest.fn());
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles errors gracefully', async () => {
|
|
||||||
mockRestorePurchases.mockRejectedValue(new Error('network error'));
|
|
||||||
const result = await executeRestore(jest.fn());
|
|
||||||
expect(result).toEqual({ success: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshes subscription status after restore', async () => {
|
|
||||||
mockRestorePurchases.mockResolvedValue({
|
|
||||||
entitlements: { active: { pro: {} } },
|
|
||||||
});
|
|
||||||
await executeRestore(jest.fn());
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
'/api/revenue-cat/get-subscription-status',
|
|
||||||
{ method: 'POST' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { useInAppPurchase } from './useInAppPurchase';
|
|
||||||
export { useInAppPurchaseStore } from './store';
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface InAppPurchaseState {
|
|
||||||
isReady: boolean;
|
|
||||||
offerings: any | null;
|
|
||||||
isSubscribed: boolean;
|
|
||||||
setIsSubscribed: (isSubscribed: boolean) => void;
|
|
||||||
setOfferings: (offerings: any | null) => void;
|
|
||||||
setIsReady: (isReady: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInAppPurchaseStore = create<InAppPurchaseState>((set) => ({
|
|
||||||
isReady: false,
|
|
||||||
offerings: null,
|
|
||||||
isSubscribed: false,
|
|
||||||
setIsSubscribed: (isSubscribed) => set({ isSubscribed }),
|
|
||||||
setOfferings: (offerings) => set({ offerings }),
|
|
||||||
setIsReady: (isReady) => set({ isReady }),
|
|
||||||
}));
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import Purchases, { LOG_LEVEL, PRODUCT_CATEGORY } from 'react-native-purchases';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
|
||||||
import { useInAppPurchaseStore } from './store';
|
|
||||||
|
|
||||||
export const RETRY_ATTEMPTS = 3;
|
|
||||||
export const RETRY_DELAY_MS = 1500;
|
|
||||||
|
|
||||||
export const getRevenueCatAPIKey = (): string | undefined => {
|
|
||||||
if (process.env.EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT') {
|
|
||||||
return process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
|
||||||
}
|
|
||||||
return Platform.select({
|
|
||||||
ios: process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY,
|
|
||||||
android: process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY,
|
|
||||||
web: process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loadOfferings(setOfferings: (o: any) => void) {
|
|
||||||
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
const result = await Purchases.getOfferings();
|
|
||||||
if (result?.current) {
|
|
||||||
setOfferings(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
`RevenueCat offerings loaded but no current offering (attempt ${attempt + 1}/${RETRY_ATTEMPTS})`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`Failed to load offerings (attempt ${attempt + 1}/${RETRY_ATTEMPTS}):`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (attempt < RETRY_ATTEMPTS - 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSubscriptionStatus(
|
|
||||||
setIsSubscribed: (v: boolean) => void
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/revenue-cat/get-subscription-status', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to check subscription status');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setIsSubscribed(data.hasAccess);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching subscription status:', error);
|
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initiatePurchases({
|
|
||||||
isConfigured,
|
|
||||||
setIsReady,
|
|
||||||
setOfferings,
|
|
||||||
setIsSubscribed,
|
|
||||||
}: {
|
|
||||||
isConfigured: { current: boolean };
|
|
||||||
setIsReady: (v: boolean) => void;
|
|
||||||
setOfferings: (o: any) => void;
|
|
||||||
setIsSubscribed: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
if (isConfigured.current) return;
|
|
||||||
try {
|
|
||||||
void Purchases.setLogLevel(LOG_LEVEL.INFO);
|
|
||||||
const apiKey = getRevenueCatAPIKey();
|
|
||||||
if (apiKey) {
|
|
||||||
Purchases.configure({ apiKey });
|
|
||||||
isConfigured.current = true;
|
|
||||||
await Promise.allSettled([
|
|
||||||
loadOfferings(setOfferings),
|
|
||||||
fetchSubscriptionStatus(setIsSubscribed),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
console.warn('No RevenueCat API key found for platform:', Platform.OS);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to initialize RevenueCat:', error);
|
|
||||||
} finally {
|
|
||||||
setIsReady(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvailablePackagesFromOfferings(offerings: any) {
|
|
||||||
const offering = offerings?.current;
|
|
||||||
if (!offering) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return offering.availablePackages;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSubscriptionsFromOfferings(offerings: any) {
|
|
||||||
return getAvailablePackagesFromOfferings(offerings).filter(
|
|
||||||
(pkg: any) =>
|
|
||||||
pkg.product.productCategory === PRODUCT_CATEGORY.SUBSCRIPTION
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executePurchase({
|
|
||||||
pkg,
|
|
||||||
setIsSubscribed,
|
|
||||||
}: {
|
|
||||||
pkg: any;
|
|
||||||
setIsSubscribed: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
const { customerInfo } = await Purchases.purchasePackage(pkg);
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
return { success: true, customerInfo };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.userCancelled) {
|
|
||||||
return { success: false, cancelled: true };
|
|
||||||
}
|
|
||||||
console.error('Failed to purchase:', error);
|
|
||||||
return { success: false, cancelled: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeRestore(
|
|
||||||
setIsSubscribed: (v: boolean) => void
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const customerInfo = await Purchases.restorePurchases();
|
|
||||||
await fetchSubscriptionStatus(setIsSubscribed);
|
|
||||||
return {
|
|
||||||
success: Object.keys(customerInfo.entitlements.active).length > 0,
|
|
||||||
customerInfo,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to restore purchases:', error);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInAppPurchase() {
|
|
||||||
const {
|
|
||||||
isReady,
|
|
||||||
offerings,
|
|
||||||
setOfferings,
|
|
||||||
setIsSubscribed,
|
|
||||||
isSubscribed,
|
|
||||||
setIsReady,
|
|
||||||
} = useInAppPurchaseStore();
|
|
||||||
const [isPurchasing, setIsPurchasing] = useState(false);
|
|
||||||
const isConfigured = useRef(false);
|
|
||||||
|
|
||||||
const initiate = useCallback(
|
|
||||||
() =>
|
|
||||||
initiatePurchases({
|
|
||||||
isConfigured,
|
|
||||||
setIsReady,
|
|
||||||
setOfferings,
|
|
||||||
setIsSubscribed,
|
|
||||||
}),
|
|
||||||
[setIsReady, setOfferings, setIsSubscribed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getAvailablePackages = useCallback(
|
|
||||||
() => getAvailablePackagesFromOfferings(offerings),
|
|
||||||
[offerings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getAvailableSubscriptions = useCallback(
|
|
||||||
() => getSubscriptionsFromOfferings(offerings),
|
|
||||||
[offerings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const purchasePackage = useCallback(
|
|
||||||
async ({ pkg }: { pkg: any }) => {
|
|
||||||
setIsPurchasing(true);
|
|
||||||
try {
|
|
||||||
return await executePurchase({ pkg, setIsSubscribed });
|
|
||||||
} finally {
|
|
||||||
setIsPurchasing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setIsPurchasing, setIsSubscribed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const restorePurchases = useCallback(async () => {
|
|
||||||
setIsPurchasing(true);
|
|
||||||
try {
|
|
||||||
return await executeRestore(setIsSubscribed);
|
|
||||||
} finally {
|
|
||||||
setIsPurchasing(false);
|
|
||||||
}
|
|
||||||
}, [setIsPurchasing, setIsSubscribed]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isReady,
|
|
||||||
offerings,
|
|
||||||
isSubscribed,
|
|
||||||
isPurchasing,
|
|
||||||
initiate,
|
|
||||||
getAvailablePackages,
|
|
||||||
getAvailableSubscriptions,
|
|
||||||
purchasePackage,
|
|
||||||
restorePurchases,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useInAppPurchase;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface UseHandleStreamResponseProps {
|
|
||||||
onChunk: (content: string) => void;
|
|
||||||
onFinish: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHandleStreamResponse({ onChunk, onFinish }: UseHandleStreamResponseProps) {
|
|
||||||
const handleStreamResponse = React.useCallback(
|
|
||||||
async (response: Response) => {
|
|
||||||
if (response.body) {
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
if (reader) {
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let content = '';
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
onFinish(content);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
content += chunk;
|
|
||||||
onChunk(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onChunk, onFinish]
|
|
||||||
);
|
|
||||||
return handleStreamResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useHandleStreamResponse;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import { useNavigation } from 'expo-router';
|
|
||||||
import { BackHandler } from 'react-native';
|
|
||||||
|
|
||||||
export const usePreventBack = (): void => {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useFocusEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => null,
|
|
||||||
gestureEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
navigation.getParent()?.setOptions({ gestureEnabled: false });
|
|
||||||
|
|
||||||
// Android back button handler
|
|
||||||
const hardwareBackPressHandler = BackHandler.addEventListener(
|
|
||||||
'hardwareBackPress',
|
|
||||||
() => {
|
|
||||||
// Prevent default behavior of leaving the screen
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
navigation.getParent()?.setOptions({ gestureEnabled: true });
|
|
||||||
navigation.setOptions({
|
|
||||||
gestureEnabled: true,
|
|
||||||
});
|
|
||||||
hardwareBackPressHandler.remove();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default usePreventBack;
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import type { ReactNativeAsset } from '@uploadcare/upload-client';
|
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
interface UploadInputReactNative {
|
|
||||||
reactNativeAsset: ReactNativeAsset & { file?: File };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadInputUrl {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadInputBase64 {
|
|
||||||
base64: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadInputBuffer {
|
|
||||||
buffer: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadInput = UploadInputReactNative | UploadInputUrl | UploadInputBase64 | UploadInputBuffer;
|
|
||||||
|
|
||||||
interface UploadResult {
|
|
||||||
url?: string;
|
|
||||||
mimeType?: string | null;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadHookResult {
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both paths upload via the proxy's /_create/api/upload/ (respects S3 flag).
|
|
||||||
// Web: globalThis.fetch with full proxy URL + no custom headers (avoids CORS
|
|
||||||
// preflight — the proxy adds project-group-id from the hostname server-side).
|
|
||||||
// Native: FileSystem.uploadAsync to same URL with manual auth headers.
|
|
||||||
function useUpload(): [(input: UploadInput) => Promise<UploadResult>, UploadHookResult] {
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const upload = React.useCallback(async (input: UploadInput): Promise<UploadResult> => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
let response: Response | undefined;
|
|
||||||
if ('reactNativeAsset' in input && input.reactNativeAsset) {
|
|
||||||
const asset = input.reactNativeAsset;
|
|
||||||
|
|
||||||
if (asset.file) {
|
|
||||||
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', asset.file);
|
|
||||||
response = await globalThis.fetch(`${proxyBaseUrl}/_create/api/upload/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const FileSystem = require('expo-file-system/legacy');
|
|
||||||
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
|
||||||
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
|
||||||
const host = process.env.EXPO_PUBLIC_HOST;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'x-createxyz-project-group-id': projectGroupId || '',
|
|
||||||
'host': host || '',
|
|
||||||
'x-forwarded-host': host || '',
|
|
||||||
'x-createxyz-host': host || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authStr = await SecureStore.getItemAsync(`${projectGroupId}-jwt`);
|
|
||||||
if (authStr) {
|
|
||||||
const auth = JSON.parse(authStr);
|
|
||||||
if (auth?.jwt) headers['authorization'] = `Bearer ${auth.jwt}`;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const uploadResult = await FileSystem.uploadAsync(
|
|
||||||
`${proxyBaseUrl}/_create/api/upload/`,
|
|
||||||
asset.uri,
|
|
||||||
{
|
|
||||||
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
|
|
||||||
fieldName: 'file',
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uploadResult.status < 200 || uploadResult.status >= 300) {
|
|
||||||
throw new Error(`Upload failed (${uploadResult.status}): ${uploadResult.body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: { url?: string; mimeType?: string | null };
|
|
||||||
try {
|
|
||||||
data = JSON.parse(uploadResult.body);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Upload failed: invalid response from upload service');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { url: data.url, mimeType: data.mimeType || null };
|
|
||||||
}
|
|
||||||
} else if ('url' in input) {
|
|
||||||
response = await fetch('/_create/api/upload/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ url: input.url }),
|
|
||||||
});
|
|
||||||
} else if ('base64' in input) {
|
|
||||||
response = await fetch('/_create/api/upload/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ base64: input.base64 }),
|
|
||||||
});
|
|
||||||
} else if ('buffer' in input) {
|
|
||||||
response = await fetch('/_create/api/upload/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
},
|
|
||||||
body: input.buffer as unknown as BodyInit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
if (response?.status === 413) {
|
|
||||||
throw new Error('Upload failed: File too large.');
|
|
||||||
}
|
|
||||||
const body = await response?.text().catch(() => '');
|
|
||||||
throw new Error(`Upload failed (${response?.status ?? 'no response'}): ${body}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return { url: data.url, mimeType: data.mimeType || null };
|
|
||||||
} catch (uploadError) {
|
|
||||||
if (uploadError instanceof Error) {
|
|
||||||
return { error: uploadError.message };
|
|
||||||
}
|
|
||||||
if (typeof uploadError === 'string') {
|
|
||||||
return { error: uploadError };
|
|
||||||
}
|
|
||||||
return { error: 'Upload failed' };
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [upload, { loading }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useUpload };
|
|
||||||
export default useUpload;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import config from '@anythingai/app/tailwind.config';
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ['./node_modules/@anythingai/app/**/*.{js,ts,jsx,tsx}'],
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"types": ["jest"],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts",
|
|
||||||
"nativewind-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user