Initial commit: code as received (Create/Anything export)
Insole-production time tracker exported from the Create/Anything AI platform. Baseline snapshot before any reverse-engineering or cleanup. - apps/mobile: Expo Router app (iOS/Android/web), the only workspace - publisher/: standalone OpenNext/AWS deploy tooling for the web side - Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
41
.easignore
Normal file
41
.easignore
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# dependencies (yarn install)
|
||||
node_modules
|
||||
**/node_modules
|
||||
package-lock.json
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
e2b.local.toml
|
||||
e2b.*.local.toml
|
||||
|
||||
**/.react-router
|
||||
apps/mobile/caches/*
|
||||
|
||||
# anything
|
||||
.anything
|
||||
|
||||
# playwright
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
15
.oxfmtrc.json
Normal file
15
.oxfmtrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"ignorePatterns": ["examples/**/*"]
|
||||
}
|
||||
21
.oxlintrc.json
Normal file
21
.oxlintrc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript"],
|
||||
"rules": {
|
||||
"eslint/no-const-assign": "deny",
|
||||
"eslint/constructor-super": "deny",
|
||||
"eslint/no-this-before-super": "deny",
|
||||
"eslint/no-obj-calls": "deny",
|
||||
"eslint/no-new-native-nonconstructor": "deny",
|
||||
"eslint/no-unsafe-optional-chaining": "deny",
|
||||
"eslint/no-import-assign": "deny",
|
||||
"eslint/valid-typeof": "deny",
|
||||
"react/jsx-key": "deny",
|
||||
"react/jsx-no-undef": "deny",
|
||||
"react/no-direct-mutation-state": "deny",
|
||||
"react/exhaustive-deps": "deny",
|
||||
"typescript/no-misused-spread": "deny",
|
||||
"oxc/missing-throw": "deny",
|
||||
"oxc/uninvoked-array-callback": "deny"
|
||||
}
|
||||
}
|
||||
15
.yarn/patches/@expo+cli+54.0.1.patch
Normal file
15
.yarn/patches/@expo+cli+54.0.1.patch
Normal file
@@ -0,0 +1,15 @@
|
||||
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: {
|
||||
24
.yarn/patches/@expo+metro-runtime+6.1.2.patch
Normal file
24
.yarn/patches/@expo+metro-runtime+6.1.2.patch
Normal file
@@ -0,0 +1,24 @@
|
||||
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}>
|
||||
42
.yarn/patches/@react-native-community+netinfo+11.4.1.patch
Normal file
42
.yarn/patches/@react-native-community+netinfo+11.4.1.patch
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
506
.yarn/patches/expo-router+6.0.11.patch
Normal file
506
.yarn/patches/expo-router+6.0.11.patch
Normal file
@@ -0,0 +1,506 @@
|
||||
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>)}
|
||||
375
.yarn/patches/expo-store-review+9.0.8.patch
Normal file
375
.yarn/patches/expo-store-review+9.0.8.patch
Normal file
File diff suppressed because one or more lines are too long
30
.yarn/patches/react-native+0.81.4.patch
Normal file
30
.yarn/patches/react-native+0.81.4.patch
Normal file
@@ -0,0 +1,30 @@
|
||||
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',
|
||||
- );
|
||||
- },
|
||||
- });
|
||||
}
|
||||
17
.yarn/patches/react-native-purchases+9.6.1.patch
Normal file
17
.yarn/patches/react-native-purchases+9.6.1.patch
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
59
.yarn/patches/react-native-purchases-ui+9.6.1.patch
Normal file
59
.yarn/patches/react-native-purchases-ui+9.6.1.patch
Normal file
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
24
.yarn/patches/react-native-web-refresh-control+1.1.2.patch
Normal file
24
.yarn/patches/react-native-web-refresh-control+1.1.2.patch
Normal file
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
43
.yarn/patches/sonner-native+0.21.0.patch
Normal file
43
.yarn/patches/sonner-native+0.21.0.patch
Normal file
@@ -0,0 +1,43 @@
|
||||
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
|
||||
});
|
||||
16
.yarnrc.yml
Normal file
16
.yarnrc.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
nodeLinker: node-modules
|
||||
nmMode: hardlinks-global
|
||||
|
||||
# These peer dep warnings are from upstream packages with stale ranges that
|
||||
# work fine with our versions. @lshay/ui declares react 18 / tailwindcss 3
|
||||
# but works with 19 / 4; expo-three uses old expo SDK ranges;
|
||||
# @expo/cli still requests the deprecated @types/react-native.
|
||||
logFilters:
|
||||
- code: YN0060
|
||||
level: discard
|
||||
- code: YN0002
|
||||
level: discard
|
||||
- code: YN0068
|
||||
level: discard
|
||||
- code: YN0086
|
||||
level: discard
|
||||
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A cross-platform (iOS / Android / web) **time-tracking app for insole (orthotics) production**, built on the **"Create" / Anything AI** platform and exported to run locally. The UI is in **Dutch**. The user picks an insole type (`Type zool`: Kurk / Berk / 3D), a handling/task (`Type handeling`), and a count (`Aantal zolen`, default 2), then runs a stopwatch (start / pause / stop & save / double-press discard). History is exportable to CSV (nl-BE locale, `HH:MM:SS` durations); the Settings tab manages handelingen per zooltype.
|
||||
|
||||
**This repo is frontend-only.** The backend API routes (`/api/tasks`, `/api/logs`) and the database (`production_tasks`, `time_logs` tables) live on the remote Create web app, reached via `EXPO_PUBLIC_BASE_URL` (see `apps/mobile/.env`). Editing tasks/history happens against that remote DB, not in this code.
|
||||
|
||||
## Layout
|
||||
|
||||
- Yarn 4 (Berry) monorepo, `node-modules` linker. Workspaces = `apps/*`; only **`apps/mobile`** (the Expo app) exists.
|
||||
- **`publisher/`** is NOT a workspace — it's a standalone OpenNext + AWS S3 tool (its own `yarn.lock`) for building/deploying the Next.js *web* side. Rarely touched.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repo root unless noted. There are no `start`/`lint`/`test` npm scripts — invoke the tools directly.
|
||||
|
||||
```bash
|
||||
yarn install # install (Yarn 4)
|
||||
npx oxlint # lint (config: .oxlintrc.json) — this is the real linter
|
||||
npx oxfmt # format (config: .oxfmtrc.json) — 2-space, single-quote, semi, width 100
|
||||
|
||||
cd apps/mobile
|
||||
npx expo start # dev server; press a/i/w or scan the QR with Expo Go
|
||||
npx expo start --web # web target only
|
||||
npx tsc --noEmit # typecheck (strict; @/* -> src/*)
|
||||
yarn jest # all tests (jest-expo preset)
|
||||
yarn jest src/utils/iap/__tests__/useInAppPurchase.test.ts # single file
|
||||
yarn jest -t "name" # single test by name
|
||||
|
||||
eas build --profile <development|preview|production> --platform <android|ios> # native builds (eas.json)
|
||||
```
|
||||
|
||||
> `eslint` / `typescript-eslint` are in devDeps but there is **no eslint config** in the tree — use **oxlint**, not eslint.
|
||||
|
||||
## Architecture (the parts that span files)
|
||||
|
||||
- **Routing:** Expo Router, file-based under `apps/mobile/src/app/`. `_layout.tsx` is the root: it gates render on `useAuth().initiate()` + `isReady` (loads the persisted session before showing anything) and provides the React Query client. `(tabs)/` holds the three screens — `index.tsx` (Stopwatch), `history.tsx` (Geschiedenis), `tasks.tsx` (Instellingen).
|
||||
- **Platform vs web entry points are split by file extension.** Native: `index.tsx` → `entrypoint.ts` → `App.tsx`. Web: `index.web.tsx` → `App.web.tsx`. The `.web.*` files add sandbox-iframe plumbing (postMessage handshake, navigation sync, screenshot capture, healthcheck) used by the Create preview panel — this is why the web root looks very different from the native one.
|
||||
- **Global `fetch` is monkey-patched.** `src/__create/polyfills.ts` replaces `global.fetch` with `src/__create/fetch.ts`, which rewrites first-party (`/...`) URLs onto `EXPO_PUBLIC_BASE_URL`, injects project/host headers, and attaches the SecureStore JWT. App code calls `fetch('/api/...')` and relies on this. For explicit auth, `src/utils/auth/getSession.ts` exposes `authFetch` (better-auth bearer JWT).
|
||||
- **Web support is achieved via Metro module aliasing**, not separate web code. `apps/mobile/metro.config.js` `resolveRequest` swaps a large set of native modules for stubs in `polyfills/web/` when `platform === 'web'` (and a few in `polyfills/native/`, plus dev-only stubs like `react-native-purchases` outside production). **Consequence: adding a native dependency that gets imported on web requires adding a web polyfill alias here, or the web build breaks.**
|
||||
- **Styling:** NativeWind/Tailwind config extends `@anythingai/app/tailwind.config`, but the screens themselves mostly use React Native `StyleSheet`/inline styles. Inter is the font.
|
||||
- **Environment gating:** `EXPO_PUBLIC_CREATE_ENV` (`PRODUCTION` / `DEVELOPMENT`) and `__DEV__` gate analytics, Sentry, the in-app "anything-menu", and dev-only native aliases. Real native SDKs only load in production builds.
|
||||
|
||||
## Conventions & gotchas
|
||||
|
||||
- **Do not edit platform-managed files.** Files under `apps/mobile/__create/` and `apps/mobile/src/__create/`, and the auth files in `src/utils/auth/` (`useAuth.ts`, `getSession.ts`, etc.), carry `⚠ ANYTHING PLATFORM — DO NOT REWRITE` headers. They define the public auth surface (`signIn`/`signUp`/`signOut`/`auth`/`isAuthenticated`/`isReady`) and the fetch/sandbox plumbing; rewriting them breaks auth or the Create preview. `src/app/_layout.tsx` is editable except for the `<AuthModal />` render and the `initiate()` + `isReady` gate.
|
||||
- **Do not bump patched dependencies.** `react-native`, `expo-router`, `expo-store-review`, `react-native-purchases(-ui)`, `@expo/cli`, `@react-native-community/netinfo`, and others use `patch:` entries in `package.json` backed by `.yarn/patches/`, pinned further by root `resolutions`/`overrides`. Upgrading them discards the patch and breaks the app. When running `npx expo install --fix`, skip any package marked `patch:`.
|
||||
- This is an exported template: package versions are pinned exact (no `^`) deliberately. Prefer minimal, targeted changes.
|
||||
38
apps/mobile/.easignore
Normal file
38
apps/mobile/.easignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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
Normal file
44
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# 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/*
|
||||
16
apps/mobile/App.tsx
Normal file
16
apps/mobile/App.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
apps/mobile/App.web.tsx
Normal file
175
apps/mobile/App.web.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
79
apps/mobile/__create/handle-resolve-request-error.js
Normal file
79
apps/mobile/__create/handle-resolve-request-error.js
Normal file
@@ -0,0 +1,79 @@
|
||||
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,
|
||||
};
|
||||
53
apps/mobile/__create/report-error-to-remote.js
Normal file
53
apps/mobile/__create/report-error-to-remote.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
};
|
||||
115
apps/mobile/__create/report-error-to-remote.test.js
Normal file
115
apps/mobile/__create/report-error-to-remote.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
78
apps/mobile/__create/sentry.ts
Normal file
78
apps/mobile/__create/sentry.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
292
apps/mobile/__create/testflight-logger.ts
Normal file
292
apps/mobile/__create/testflight-logger.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
82
apps/mobile/app.json
Normal file
82
apps/mobile/app.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/assets/images/adaptive-icon.png
Normal file
BIN
apps/mobile/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
BIN
apps/mobile/assets/images/favicon.png
Normal file
BIN
apps/mobile/assets/images/favicon.png
Normal file
Binary file not shown.
BIN
apps/mobile/assets/images/icon.png
Normal file
BIN
apps/mobile/assets/images/icon.png
Normal file
Binary file not shown.
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
Binary file not shown.
6
apps/mobile/babel.config.js
Normal file
6
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]],
|
||||
};
|
||||
};
|
||||
40
apps/mobile/eas.json
Normal file
40
apps/mobile/eas.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/mobile/entrypoint.ts
Normal file
3
apps/mobile/entrypoint.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import App from './App';
|
||||
|
||||
export default App;
|
||||
8320
apps/mobile/fontawesome.css
vendored
Normal file
8320
apps/mobile/fontawesome.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
5
apps/mobile/global.css
Normal file
5
apps/mobile/global.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@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
Normal file
33
apps/mobile/global.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
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' {}
|
||||
30
apps/mobile/index.tsx
Normal file
30
apps/mobile/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
127
apps/mobile/index.web.tsx
Normal file
127
apps/mobile/index.web.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
});
|
||||
233
apps/mobile/metro.config.js
Normal file
233
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
133
apps/mobile/package.json
Normal file
133
apps/mobile/package.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"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/ngrok": "4.1.3",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
22
apps/mobile/patches/react-native+0.81.4.patch
Normal file
22
apps/mobile/patches/react-native+0.81.4.patch
Normal file
@@ -0,0 +1,22 @@
|
||||
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 {
|
||||
424
apps/mobile/polyfills/native/google-mobile-ads.native.tsx
Normal file
424
apps/mobile/polyfills/native/google-mobile-ads.native.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
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;
|
||||
179
apps/mobile/polyfills/native/react-native-purchases.native.tsx
Normal file
179
apps/mobile/polyfills/native/react-native-purchases.native.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
// 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;
|
||||
16
apps/mobile/polyfills/native/textinput.native.tsx
Normal file
16
apps/mobile/polyfills/native/textinput.native.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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
apps/mobile/polyfills/shared/empty-component.tsx
Normal file
1
apps/mobile/polyfills/shared/empty-component.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default () => null;
|
||||
99
apps/mobile/polyfills/shared/expo-image.tsx
Normal file
99
apps/mobile/polyfills/shared/expo-image.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
38
apps/mobile/polyfills/web/SafeAreaView.web.tsx
Normal file
38
apps/mobile/polyfills/web/SafeAreaView.web.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
521
apps/mobile/polyfills/web/alerts.web.tsx
Normal file
521
apps/mobile/polyfills/web/alerts.web.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
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;
|
||||
241
apps/mobile/polyfills/web/camera.web.tsx
Normal file
241
apps/mobile/polyfills/web/camera.web.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
49
apps/mobile/polyfills/web/clipboard.web.ts
Normal file
49
apps/mobile/polyfills/web/clipboard.web.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
299
apps/mobile/polyfills/web/contacts.web.ts
Normal file
299
apps/mobile/polyfills/web/contacts.web.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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,
|
||||
};
|
||||
94
apps/mobile/polyfills/web/documentPicker.web.ts
Normal file
94
apps/mobile/polyfills/web/documentPicker.web.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
10
apps/mobile/polyfills/web/expo-font.web.ts
Normal file
10
apps/mobile/polyfills/web/expo-font.web.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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 };
|
||||
}
|
||||
424
apps/mobile/polyfills/web/google-mobile-ads.web.tsx
Normal file
424
apps/mobile/polyfills/web/google-mobile-ads.web.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
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;
|
||||
61
apps/mobile/polyfills/web/haptics.web.ts
Normal file
61
apps/mobile/polyfills/web/haptics.web.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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]);
|
||||
};
|
||||
203
apps/mobile/polyfills/web/imagePicker.web.ts
Normal file
203
apps/mobile/polyfills/web/imagePicker.web.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
53
apps/mobile/polyfills/web/linking.web.ts
Normal file
53
apps/mobile/polyfills/web/linking.web.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
205
apps/mobile/polyfills/web/location.web.ts
Normal file
205
apps/mobile/polyfills/web/location.web.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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;
|
||||
51
apps/mobile/polyfills/web/maps.web.tsx
Normal file
51
apps/mobile/polyfills/web/maps.web.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
80
apps/mobile/polyfills/web/notifications.web.tsx
Normal file
80
apps/mobile/polyfills/web/notifications.web.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
};
|
||||
3
apps/mobile/polyfills/web/refreshControl.web.tsx
Normal file
3
apps/mobile/polyfills/web/refreshControl.web.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { RefreshControl } from 'react-native-web-refresh-control';
|
||||
|
||||
export default RefreshControl;
|
||||
25
apps/mobile/polyfills/web/safeAreaContext.web.tsx
Normal file
25
apps/mobile/polyfills/web/safeAreaContext.web.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
};
|
||||
23
apps/mobile/polyfills/web/scrollview.web.tsx
Normal file
23
apps/mobile/polyfills/web/scrollview.web.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
111
apps/mobile/polyfills/web/secureStore.web.ts
Normal file
111
apps/mobile/polyfills/web/secureStore.web.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
72
apps/mobile/polyfills/web/statusBar.web.tsx
Normal file
72
apps/mobile/polyfills/web/statusBar.web.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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 };
|
||||
24
apps/mobile/polyfills/web/tabbar.web.tsx
Normal file
24
apps/mobile/polyfills/web/tabbar.web.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
56
apps/mobile/polyfills/web/webBrowser.web.ts
Normal file
56
apps/mobile/polyfills/web/webBrowser.web.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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 };
|
||||
}
|
||||
109
apps/mobile/polyfills/web/webview.web.tsx
Normal file
109
apps/mobile/polyfills/web/webview.web.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
6
apps/mobile/postcss.config.js
Normal file
6
apps/mobile/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
apps/mobile/public/canvaskit.wasm
Normal file
BIN
apps/mobile/public/canvaskit.wasm
Normal file
Binary file not shown.
105
apps/mobile/src/__create/ErrorBoundary.tsx
Normal file
105
apps/mobile/src/__create/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
84
apps/mobile/src/__create/analytics.ts
Normal file
84
apps/mobile/src/__create/analytics.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
19
apps/mobile/src/__create/anything-menu.ios.tsx
Normal file
19
apps/mobile/src/__create/anything-menu.ios.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
586
apps/mobile/src/__create/anything-menu.tsx
Normal file
586
apps/mobile/src/__create/anything-menu.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
106
apps/mobile/src/__create/fetch.ts
Normal file
106
apps/mobile/src/__create/fetch.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
20
apps/mobile/src/__create/placeholder.svg
Normal file
20
apps/mobile/src/__create/placeholder.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
apps/mobile/src/__create/polyfills.ts
Normal file
3
apps/mobile/src/__create/polyfills.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import updatedFetch from './fetch';
|
||||
// @ts-expect-error -- updatedFetch wraps the native fetch with custom headers
|
||||
global.fetch = updatedFetch;
|
||||
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
436
apps/mobile/src/app/+not-found.tsx
Normal file
436
apps/mobile/src/app/+not-found.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
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 />
|
||||
);
|
||||
};
|
||||
71
apps/mobile/src/app/_layout.tsx
Normal file
71
apps/mobile/src/app/_layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
3
apps/mobile/src/app/index.tsx
Normal file
3
apps/mobile/src/app/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Index() {
|
||||
return null;
|
||||
}
|
||||
147
apps/mobile/src/components/KeyboardAvoidingAnimatedView.tsx
Normal file
147
apps/mobile/src/components/KeyboardAvoidingAnimatedView.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
163
apps/mobile/src/utils/auth/AuthWebView.tsx
Normal file
163
apps/mobile/src/utils/auth/AuthWebView.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ⚠ 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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
40
apps/mobile/src/utils/auth/getSession.ts
Normal file
40
apps/mobile/src/utils/auth/getSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* ⚠ 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 });
|
||||
};
|
||||
14
apps/mobile/src/utils/auth/index.ts
Normal file
14
apps/mobile/src/utils/auth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ⚠ 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;
|
||||
95
apps/mobile/src/utils/auth/store.ts
Normal file
95
apps/mobile/src/utils/auth/store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ⚠ 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 }),
|
||||
}));
|
||||
101
apps/mobile/src/utils/auth/useAuth.ts
Normal file
101
apps/mobile/src/utils/auth/useAuth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ⚠ 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;
|
||||
104
apps/mobile/src/utils/auth/useAuthModal.tsx
Normal file
104
apps/mobile/src/utils/auth/useAuthModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ⚠ 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;
|
||||
24
apps/mobile/src/utils/auth/useUser.ts
Normal file
24
apps/mobile/src/utils/auth/useUser.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ⚠ 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;
|
||||
@@ -0,0 +1,17 @@
|
||||
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' };
|
||||
@@ -0,0 +1,4 @@
|
||||
export const Platform = {
|
||||
select: (opts: Record<string, any>) => opts.ios,
|
||||
OS: 'ios',
|
||||
};
|
||||
382
apps/mobile/src/utils/iap/__tests__/useInAppPurchase.test.ts
Normal file
382
apps/mobile/src/utils/iap/__tests__/useInAppPurchase.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
2
apps/mobile/src/utils/iap/index.ts
Normal file
2
apps/mobile/src/utils/iap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useInAppPurchase } from './useInAppPurchase';
|
||||
export { useInAppPurchaseStore } from './store';
|
||||
19
apps/mobile/src/utils/iap/store.ts
Normal file
19
apps/mobile/src/utils/iap/store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 }),
|
||||
}));
|
||||
211
apps/mobile/src/utils/iap/useInAppPurchase.ts
Normal file
211
apps/mobile/src/utils/iap/useInAppPurchase.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user