chore: remove legacy apps/mobile, apps/web, publisher and dead root config

This commit is contained in:
Bas van Rossem
2026-06-17 14:38:46 +02:00
parent c72086550d
commit 64f8b2fd2c
189 changed files with 28 additions and 48292 deletions

View File

@@ -1,38 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# typescript
*.tsbuildinfo
app-example
caches/
public/

View File

@@ -1,44 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
.env
.metro-virtual/*
caches/*

View File

@@ -1,16 +0,0 @@
import { App } from 'expo-router/build/qualified-entry';
import { ScreenViewTracker } from './src/__create/analytics';
// Screen-view analytics is mounted here, in the entry, rather than in
// app/_layout. The entry is platform scaffold that ships with the template, so
// the tracker reaches every app on its next rebuild WITHOUT editing each
// project's own _layout (mirrors how App.web.tsx tracks navigation at the
// root). usePathname reads expo-router's global store.
export default function MobileRoot() {
return (
<>
<ScreenViewTracker />
<App />
</>
);
}

View File

@@ -1,175 +0,0 @@
import { usePathname, useRouter } from 'expo-router';
import { App } from 'expo-router/build/qualified-entry';
import React, { memo, useEffect } from 'react';
import './src/__create/polyfills';
import { ErrorBoundary } from './src/__create/ErrorBoundary';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Toaster } from 'sonner-native';
import { AlertModal } from './polyfills/web/alerts.web';
import './global.css';
const RUNTIME_ERROR_PATTERNS = [
/fetch failed/i,
/networks*(error|request)/i,
/failed to fetch/i,
/load failed/i,
/ECONNREFUSED/i,
/ECONNRESET/i,
/ETIMEDOUT/i,
/ENOTFOUND/i,
/ERR_CONNECTION/i,
/aborted/i,
/timeout/i,
/socket hang up/i,
/503\b/,
/502\b/,
/504\b/,
/getaddrinfo/i,
];
function isRuntimeError(msg: string) {
return RUNTIME_ERROR_PATTERNS.some((p) => p.test(msg));
}
function postErrorToParent(message: string, name: string, stack: string) {
try {
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'sandbox:error:detected',
error: { message, name, stack },
},
'*'
);
}
} catch {}
}
const GlobalErrorReporter = () => {
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const errorHandler = (event: ErrorEvent) => {
if (typeof event.preventDefault === 'function') event.preventDefault();
console.error(event.error);
const error = event.error;
const message = error?.message || event.message || 'Unknown error';
if (!isRuntimeError(message)) {
postErrorToParent(message, error?.name || 'Error', error?.stack || '');
}
};
const unhandledRejectionHandler = (event: PromiseRejectionEvent) => {
if (typeof event.preventDefault === 'function') event.preventDefault();
const reason = event.reason;
console.error('Unhandled promise rejection:', reason);
const message = reason?.message || String(reason || '');
if (isRuntimeError(message)) return;
const isCodeError =
reason instanceof TypeError ||
reason instanceof ReferenceError ||
reason instanceof SyntaxError ||
reason?.code === 'MODULE_RESOLVE_FAILED';
if (!isCodeError) return;
postErrorToParent(message, reason?.name || 'Error', reason?.stack || '');
};
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', unhandledRejectionHandler);
return () => {
window.removeEventListener('error', errorHandler);
window.removeEventListener(
'unhandledrejection',
unhandledRejectionHandler
);
};
}, []);
return null;
};
const Wrapper = memo(() => {
return (
<ErrorBoundary>
<SafeAreaProvider
initialMetrics={{
insets: { top: 64, bottom: 34, left: 0, right: 0 },
frame: {
x: 0,
y: 0,
width: typeof window === 'undefined' ? 390 : window.innerWidth,
height: typeof window === 'undefined' ? 844 : window.innerHeight,
},
}}
>
<App />
<GlobalErrorReporter />
<Toaster />
</SafeAreaProvider>
</ErrorBoundary>
);
});
const healthyResponse = {
type: 'sandbox:mobile:healthcheck:response',
healthy: true,
};
const useHandshakeParent = () => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'sandbox:mobile:healthcheck') {
window.parent.postMessage(healthyResponse, '*');
}
};
window.addEventListener('message', handleMessage);
// Immediately respond to the parent window with a healthy response in
// case we missed the healthcheck message
window.parent.postMessage(healthyResponse, '*');
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
};
const CreateApp = () => {
const router = useRouter();
const pathname = usePathname();
useHandshakeParent();
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (
event.data.type === 'sandbox:navigation' &&
event.data.pathname !== pathname
) {
router.push(event.data.pathname);
}
};
window.addEventListener('message', handleMessage);
window.parent.postMessage({ type: 'sandbox:mobile:ready' }, '*');
return () => {
window.removeEventListener('message', handleMessage);
};
}, [router, pathname]);
useEffect(() => {
window.parent.postMessage(
{
type: 'sandbox:mobile:navigation',
pathname,
},
'*'
);
}, [pathname]);
return (
<>
<Wrapper />
<AlertModal />
</>
);
};
export default CreateApp;

View File

@@ -1,79 +0,0 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { reportErrorToRemote } = require('./report-error-to-remote');
const VIRTUAL_ROOT = path.join(__dirname, '../.metro-virtual');
const VIRTUAL_ROOT_UNRESOLVED = path.join(VIRTUAL_ROOT, 'unresolved');
const handleResolveRequestError = ({
error,
context,
moduleName,
platform,
}) => {
const errorMessage = `Unable to resolve module '${moduleName}' from '${context.originModulePath}'`;
const syntheticError = new Error(errorMessage);
syntheticError.stack = error.stack;
reportErrorToRemote({ error: syntheticError }).catch((_reportError) => {
// no-op
});
if (process.env.NODE_ENV === 'production') throw error;
if (platform === 'android') throw error;
if (!__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== 'DEVELOPMENT')
throw error;
// Build a deterministic virtual file path for this failed request
const key = `${moduleName}|${context.originModulePath}|${platform}`;
const hash = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 16);
fs.mkdirSync(VIRTUAL_ROOT_UNRESOLVED, { recursive: true });
const vfile = path.join(VIRTUAL_ROOT_UNRESOLVED, `throw-${hash}.js`);
// Serialize a safe payload for the client
const payload = {
moduleName,
from: context.originModulePath,
platform,
originalMessage: String(
error?.message ? error.message : 'Unknown resolve error'
),
};
const code = [
'// Auto generated by custom Metro resolver',
'(function(){',
` var info = ${JSON.stringify(payload)};`,
" var msg = 'Unable to resolve \"' + info.moduleName + '\" from \"' + info.from + '\"';",
" msg += '\\n\\n' + info.originalMessage;",
' var e = new Error(msg);',
" e.name = 'ModuleResolveError';",
" e.code = 'MODULE_RESOLVE_FAILED';",
' throw e;',
'})();',
'export {};', // keep ESM shape harmlessly
'',
].join('\n');
// Only write if content changed — avoids bumping mtime and triggering Metro rebuild loop
const existingContent = fs.existsSync(vfile) ? fs.readFileSync(vfile, 'utf8') : null;
if (existingContent !== code) {
fs.writeFileSync(vfile, code, 'utf8');
}
// Tell Metro to load our thrower as a real source file
return {
filePath: vfile,
type: 'sourceFile',
};
};
module.exports = {
handleResolveRequestError,
VIRTUAL_ROOT,
VIRTUAL_ROOT_UNRESOLVED,
};

View File

@@ -1,53 +0,0 @@
import { serializeError } from "serialize-error";
export const sendLogsToRemote = async (logs) => {
if (
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
) {
return { success: false };
}
try {
const response = await fetch(process.env.EXPO_PUBLIC_LOGS_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY}`,
},
body: JSON.stringify({
projectGroupId: process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
logs,
}),
});
if (!response.ok) {
return { success: false };
}
} catch (fetchError) {
return { success: false, error: fetchError };
}
return { success: true };
};
export const reportErrorToRemote = async ({ error }) => {
if (
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
) {
console.debug(
"reportErrorToRemote: Missing environment variables for logging endpoint, project group ID, or API key.",
error,
);
return { success: false };
}
return sendLogsToRemote([
{
message: JSON.stringify(serializeError(error)),
timestamp: new Date().toISOString(),
level: "error",
source: "BUILDER",
devServerId: process.env.EXPO_PUBLIC_DEV_SERVER_ID,
},
]);
};

View File

@@ -1,115 +0,0 @@
jest.mock("serialize-error", () => ({
serializeError: jest.fn((err) => ({
message: err instanceof Error ? err.message : String(err),
name: err instanceof Error ? err.name : "Error",
})),
}));
let sendLogsToRemote;
let reportErrorToRemote;
beforeEach(() => {
jest.resetAllMocks();
jest.resetModules();
delete process.env.EXPO_PUBLIC_LOGS_ENDPOINT;
delete process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
delete process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY;
delete process.env.EXPO_PUBLIC_DEV_SERVER_ID;
global.fetch = jest.fn();
// Re-require after mocks are set up
jest.doMock("serialize-error", () => ({
serializeError: jest.fn((err) => ({
message: err instanceof Error ? err.message : String(err),
name: err instanceof Error ? err.name : "Error",
})),
}));
const mod = require("./report-error-to-remote");
sendLogsToRemote = mod.sendLogsToRemote;
reportErrorToRemote = mod.reportErrorToRemote;
});
describe("sendLogsToRemote", () => {
it("returns success: false when env vars are missing", async () => {
const result = await sendLogsToRemote([{ message: "test" }]);
expect(result).toEqual({ success: false });
expect(global.fetch).not.toHaveBeenCalled();
});
it("sends logs to the endpoint with correct auth header", async () => {
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
global.fetch = jest.fn().mockResolvedValue({ ok: true });
const logs = [
{ message: "hello", level: "info", timestamp: "2026-01-01T00:00:00Z" },
];
const result = await sendLogsToRemote(logs);
expect(result).toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledWith("https://logs.test/ingest", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer key-abc",
},
body: JSON.stringify({ projectGroupId: "pg-123", logs }),
});
});
it("returns success: false on non-ok response", async () => {
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });
const result = await sendLogsToRemote([{ message: "fail" }]);
expect(result).toEqual({ success: false });
});
it("returns success: false with error on network failure", async () => {
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
const networkError = new Error("Network request failed");
global.fetch = jest.fn().mockRejectedValue(networkError);
const result = await sendLogsToRemote([{ message: "fail" }]);
expect(result).toEqual({ success: false, error: networkError });
});
});
describe("reportErrorToRemote", () => {
it("returns success: false when env vars are missing", async () => {
const result = await reportErrorToRemote({
error: new Error("test error"),
});
expect(result).toEqual({ success: false });
expect(global.fetch).not.toHaveBeenCalled();
});
it("serializes error and sends as a single log entry with source BUILDER", async () => {
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
process.env.EXPO_PUBLIC_DEV_SERVER_ID = "ds-456";
global.fetch = jest.fn().mockResolvedValue({ ok: true });
const error = new Error("something broke");
await reportErrorToRemote({ error });
expect(global.fetch).toHaveBeenCalledTimes(1);
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(body.projectGroupId).toBe("pg-123");
expect(body.logs).toHaveLength(1);
expect(body.logs[0].level).toBe("error");
expect(body.logs[0].source).toBe("BUILDER");
expect(body.logs[0].devServerId).toBe("ds-456");
expect(body.logs[0].message).toContain("something broke");
});
});

View File

@@ -1,78 +0,0 @@
import * as Sentry from "@sentry/react-native";
import { sendLogsToRemote } from "./report-error-to-remote";
function isActive(): boolean {
return (
!__DEV__ &&
process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT" &&
!!process.env.EXPO_PUBLIC_SENTRY_DSN
);
}
let initialized = false;
// Mirror a Sentry event into the Anything logs pipeline so native and JS
// crashes — including startup crashes that Sentry caches natively and reports
// on the next launch — surface in the Flux builder, not only the Sentry
// dashboard.
function forwardEventToRemote(event: Sentry.Event): void {
try {
const exception = event.exception?.values?.[0];
const lines: string[] = [];
if (exception && (exception.type || exception.value)) {
lines.push(`${exception.type ?? "Error"}: ${exception.value ?? ""}`);
} else if (typeof event.message === "string") {
lines.push(event.message);
}
const frames = exception?.stacktrace?.frames;
if (frames && frames.length > 0) {
lines.push(
frames
.slice(-20)
.reverse()
.map(
(frame) =>
` at ${frame.function ?? "?"} (${frame.filename ?? "?"}:${frame.lineno ?? 0})`,
)
.join("\n"),
);
}
const message = lines.join("\n").trim();
if (!message) return;
const timestamp =
typeof event.timestamp === "number"
? new Date(event.timestamp * 1000).toISOString()
: new Date().toISOString();
sendLogsToRemote([
{
message: `[SENTRY] ${message}`,
timestamp,
level: "error",
source: "TEST_FLIGHT",
},
]);
} catch (_err) {
// Silent
}
}
export function initSentry(): void {
try {
if (!isActive() || initialized) return;
initialized = true;
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
enableNativeCrashHandling: true,
beforeSend: (event) => {
forwardEventToRemote(event);
return event;
},
});
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
if (projectGroupId) {
Sentry.setTag("projectGroupId", projectGroupId);
}
} catch (_err) {
// Silent — Sentry must never crash the host app
}
}

View File

@@ -1,428 +0,0 @@
let mockSendLogsToRemote: jest.Mock;
let mockGetItem: jest.Mock;
let mockSetItem: jest.Mock;
let mockRemoveItem: jest.Mock;
let mockFileStore: Record<string, string>;
let capturedErrorHandler: ((error: Error, isFatal?: boolean) => void) | null;
let originalErrorUtils: unknown;
const CRASH_FILE = "/doc/testflight_crash_logs.json";
const STORAGE_KEY = "testflight_logger_pending_logs";
let originalDev: boolean;
function setDevMode(value: boolean) {
(globalThis as Record<string, unknown>).__DEV__ = value;
}
beforeEach(() => {
originalDev = (globalThis as Record<string, unknown>).__DEV__ as boolean;
jest.resetModules();
jest.useFakeTimers();
process.env.EXPO_PUBLIC_CREATE_ENV = "PRODUCTION";
mockSendLogsToRemote = jest.fn().mockResolvedValue({ success: true });
mockGetItem = jest.fn().mockResolvedValue(null);
mockSetItem = jest.fn().mockResolvedValue(undefined);
mockRemoveItem = jest.fn().mockResolvedValue(undefined);
mockFileStore = {};
capturedErrorHandler = null;
originalErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils;
(globalThis as Record<string, unknown>).ErrorUtils = {
getGlobalHandler: () => () => {},
setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => {
capturedErrorHandler = handler;
},
};
jest.doMock("./report-error-to-remote", () => ({
sendLogsToRemote: mockSendLogsToRemote,
}));
jest.doMock("@react-native-async-storage/async-storage", () => ({
getItem: mockGetItem,
setItem: mockSetItem,
removeItem: mockRemoveItem,
}));
jest.doMock("expo-file-system", () => {
class MockFile {
uri: string;
constructor(directory: string, name: string) {
this.uri = `${directory}/${name}`;
}
get exists() {
return Object.prototype.hasOwnProperty.call(mockFileStore, this.uri);
}
create() {
if (!(this.uri in mockFileStore)) mockFileStore[this.uri] = "";
}
delete() {
delete mockFileStore[this.uri];
}
write(content: string) {
mockFileStore[this.uri] = content;
}
textSync() {
return mockFileStore[this.uri] ?? "";
}
}
return { File: MockFile, Paths: { document: "/doc" } };
});
});
afterEach(() => {
jest.useRealTimers();
setDevMode(originalDev);
delete process.env.EXPO_PUBLIC_CREATE_ENV;
(globalThis as Record<string, unknown>).ErrorUtils = originalErrorUtils;
});
function loadModule() {
return require("./testflight-logger") as typeof import("./testflight-logger");
}
describe("initTestFlightLogger", () => {
it("is a no-op when __DEV__ is true", async () => {
setDevMode(true);
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
initTestFlightLogger();
expect(getTestFlightLogger()).toBeNull();
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
});
it("is a no-op when EXPO_PUBLIC_CREATE_ENV is DEVELOPMENT", async () => {
setDevMode(false);
process.env.EXPO_PUBLIC_CREATE_ENV = "DEVELOPMENT";
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
initTestFlightLogger();
expect(getTestFlightLogger()).toBeNull();
});
it("activates when not in dev mode", async () => {
setDevMode(false);
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
const logger = getTestFlightLogger();
expect(logger).not.toBeNull();
expect(logger).toHaveProperty("logError");
});
it("only creates one instance on multiple calls", async () => {
setDevMode(false);
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
const first = getTestFlightLogger();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
const second = getTestFlightLogger();
expect(first).toBe(second);
});
});
describe("console patching", () => {
it("intercepts console.log and buffers the message", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.log("test message");
await jest.advanceTimersByTimeAsync(5_000);
expect(mockSendLogsToRemote).toHaveBeenCalled();
const logs = mockSendLogsToRemote.mock.calls[0][0];
const logMessage = logs.find(
(l: Record<string, string>) => l.message === "test message",
);
expect(logMessage).toBeDefined();
expect(logMessage.level).toBe("log");
expect(logMessage.source).toBe("TEST_FLIGHT");
});
it("intercepts console.error", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.error("bad thing");
await jest.advanceTimersByTimeAsync(5_000);
expect(mockSendLogsToRemote).toHaveBeenCalled();
const logs = mockSendLogsToRemote.mock.calls[0][0];
const errorLog = logs.find(
(l: Record<string, string>) => l.message === "bad thing",
);
expect(errorLog).toBeDefined();
expect(errorLog.level).toBe("error");
});
it("serializes non-string arguments as JSON", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.log("count:", { x: 1 });
await jest.advanceTimersByTimeAsync(5_000);
const logs = mockSendLogsToRemote.mock.calls[0][0];
const entry = logs.find((l: Record<string, string>) =>
l.message.includes("count:"),
);
expect(entry.message).toBe('count: {"x":1}');
});
});
describe("logError", () => {
it("immediately flushes error entries", async () => {
setDevMode(false);
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
const logger = getTestFlightLogger()!;
logger.logError("critical failure");
// Should flush without waiting for the 5s interval
await jest.advanceTimersByTimeAsync(0);
expect(mockSendLogsToRemote).toHaveBeenCalled();
const logs = mockSendLogsToRemote.mock.calls[0][0];
expect(logs[0].message).toBe("critical failure");
expect(logs[0].level).toBe("error");
});
});
describe("buffering and flushing", () => {
it("flushes buffer every 5 seconds", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.log("entry 1");
// Not flushed yet at 3 seconds
await jest.advanceTimersByTimeAsync(3_000);
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
// Flushed at 5 seconds
await jest.advanceTimersByTimeAsync(2_000);
expect(mockSendLogsToRemote).toHaveBeenCalledTimes(1);
});
it("auto-flushes when buffer reaches 50 entries", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
for (let i = 0; i < 50; i++) {
console.log(`entry ${i}`);
}
await jest.advanceTimersByTimeAsync(0);
expect(mockSendLogsToRemote).toHaveBeenCalled();
});
});
describe("persistence and retry", () => {
it("persists logs to AsyncStorage when flush fails", async () => {
mockSendLogsToRemote.mockResolvedValue({ success: false });
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.log("will fail");
await jest.advanceTimersByTimeAsync(5_000);
expect(mockSetItem).toHaveBeenCalledWith(
STORAGE_KEY,
expect.stringContaining("will fail"),
);
});
it("restores persisted logs on startup and re-sends them", async () => {
const persistedLogs = [
{
message: "old log",
timestamp: "2026-01-01T00:00:00Z",
level: "error",
source: "TEST_FLIGHT",
sessionId: "old-session",
},
];
mockGetItem.mockResolvedValueOnce(JSON.stringify(persistedLogs));
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
expect(mockRemoveItem).toHaveBeenCalledWith(STORAGE_KEY);
expect(mockSendLogsToRemote).toHaveBeenCalledWith(persistedLogs);
});
it("re-persists restored logs if resend also fails", async () => {
const persistedLogs = [
{
message: "stubborn log",
timestamp: "2026-01-01T00:00:00Z",
level: "error",
source: "TEST_FLIGHT",
sessionId: "old-session",
},
];
mockGetItem
.mockResolvedValueOnce(JSON.stringify(persistedLogs))
.mockResolvedValueOnce(null);
mockSendLogsToRemote.mockResolvedValueOnce({ success: false });
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
expect(mockSetItem).toHaveBeenCalledWith(
STORAGE_KEY,
expect.stringContaining("stubborn log"),
);
});
it("caps persisted entries at 200", async () => {
const existingLogs = Array.from({ length: 195 }, (_, i) => ({
message: `existing ${i}`,
timestamp: "2026-01-01T00:00:00Z",
level: "log",
source: "TEST_FLIGHT",
sessionId: "s",
}));
mockSendLogsToRemote.mockResolvedValue({ success: false });
mockGetItem
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(JSON.stringify(existingLogs));
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
for (let i = 0; i < 10; i++) {
console.log(`new ${i}`);
}
await jest.advanceTimersByTimeAsync(5_000);
const setItemCalls = mockSetItem.mock.calls;
const lastCall = setItemCalls[setItemCalls.length - 1];
const saved = JSON.parse(lastCall[1]);
expect(saved.length).toBeLessThanOrEqual(200);
});
});
describe("crash persistence", () => {
it("synchronously snapshots the buffer to the crash file on a fatal error", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
console.log("breadcrumb before crash");
capturedErrorHandler!(new Error("startup boom"), true);
const crashFile = mockFileStore[CRASH_FILE];
expect(crashFile).toContain("startup boom");
expect(crashFile).toContain("breadcrumb before crash");
});
it("does not snapshot the crash file for a non-fatal error", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
capturedErrorHandler!(new Error("recoverable"), false);
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
});
it("captures the in-flight batch when a fatal error triggers auto-flush", async () => {
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
// One short of the auto-flush threshold; the fatal error is the 50th
// entry, so addEntry's auto-flush empties `buffer` before
// persistBufferSync runs.
for (let i = 0; i < 49; i++) {
console.log(`entry ${i}`);
}
capturedErrorHandler!(new Error("boundary crash"), true);
const crashFile = mockFileStore[CRASH_FILE];
expect(crashFile).toContain("boundary crash");
expect(crashFile).toContain("entry 0");
});
it("ships crash-file logs on the next startup and clears the file", async () => {
mockFileStore[CRASH_FILE] = JSON.stringify([
{
message: "[FATAL] crashed last run",
timestamp: "2026-01-01T00:00:00Z",
level: "error",
source: "TEST_FLIGHT",
sessionId: "prev",
},
]);
setDevMode(false);
const { initTestFlightLogger } = loadModule();
initTestFlightLogger();
await jest.advanceTimersByTimeAsync(0);
expect(mockSendLogsToRemote).toHaveBeenCalled();
const sent = mockSendLogsToRemote.mock.calls[0][0];
expect(
sent.some(
(entry: Record<string, string>) =>
entry.message === "[FATAL] crashed last run",
),
).toBe(true);
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
});
});

View File

@@ -1,292 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { File, Paths } from "expo-file-system";
import { AppState, type AppStateStatus } from "react-native";
import { sendLogsToRemote } from "./report-error-to-remote";
const STORAGE_KEY = "testflight_logger_pending_logs";
// Written synchronously from the crash handlers so logs survive a startup
// crash that tears down the JS runtime before the async network / AsyncStorage
// paths can finish. Shipped and cleared on the next launch.
const CRASH_FILE_NAME = "testflight_crash_logs.json";
const MAX_STORED_ENTRIES = 200;
const MAX_BUFFER_SIZE = 50;
const FLUSH_INTERVAL_MS = 5_000;
interface LogEntry {
message: string;
timestamp: string;
level: "log" | "info" | "warn" | "error" | "debug";
source: "TEST_FLIGHT";
sessionId: string;
}
function isActive(): boolean {
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
}
function generateSessionId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
let instance: TestFlightLogger | null = null;
class TestFlightLogger {
private buffer: LogEntry[] = [];
// Entries spliced out of `buffer` by an in-flight `flush()` that hasn't
// confirmed delivery yet. Tracked so a crash mid-flush can still snapshot
// them — `buffer` alone would miss them.
private inFlightBatch: LogEntry[] = [];
private sessionId: string;
private flushTimer: ReturnType<typeof setInterval> | null = null;
private originalConsole: Record<string, (...args: unknown[]) => void> = {};
private isFlushing = false;
constructor() {
this.sessionId = generateSessionId();
}
async start(): Promise<void> {
try {
await this.restorePersistedLogs();
this.patchConsole();
this.hookUncaughtExceptions();
this.hookUnhandledRejections();
this.hookAppState();
this.flushTimer = setInterval(() => {
this.flush();
}, FLUSH_INTERVAL_MS);
} catch (_err) {
// Silent — the logger must never crash the host app
}
}
logError(message: string): void {
try {
this.addEntry("error", message);
this.flush();
} catch (_err) {
// Silent
}
}
private addEntry(level: LogEntry["level"], message: string): void {
this.buffer.push({
message,
timestamp: new Date().toISOString(),
level,
source: "TEST_FLIGHT",
sessionId: this.sessionId,
});
if (this.buffer.length >= MAX_BUFFER_SIZE) {
this.flush();
}
}
private patchConsole(): void {
const levels = ["log", "info", "warn", "error", "debug"] as const;
for (const level of levels) {
this.originalConsole[level] = console[level].bind(console);
console[level] = (...args: unknown[]) => {
try {
const message = args
.map((arg) => {
if (typeof arg === "string") return arg;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
})
.join(" ");
this.addEntry(level, message);
} catch (_err) {
// Silent
}
this.originalConsole[level]?.(...args);
};
}
}
private hookUncaughtExceptions(): void {
const ErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils as
| {
getGlobalHandler: () => (error: Error, isFatal?: boolean) => void;
setGlobalHandler: (
handler: (error: Error, isFatal?: boolean) => void,
) => void;
}
| undefined;
if (!ErrorUtils) return;
const previousHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
try {
const tag = isFatal ? "[FATAL]" : "[UNCAUGHT]";
this.addEntry("error", `${tag} ${error.message}\n${error.stack ?? ""}`);
// Only a fatal error tears down the runtime before the async
// flush can finish, so only then is the synchronous crash-file
// snapshot needed. Non-fatal errors are delivered by flush /
// AsyncStorage retry; snapshotting them would just duplicate
// entries on the next launch.
if (isFatal) {
this.persistBufferSync();
}
this.flush();
} catch (_err) {
// Silent
}
previousHandler(error, isFatal);
});
}
private hookUnhandledRejections(): void {
const previous: ((event: PromiseRejectionEvent) => void) | null =
globalThis.onunhandledrejection;
globalThis.onunhandledrejection = (event: PromiseRejectionEvent) => {
try {
const reason =
event.reason instanceof Error
? `${event.reason.message}\n${event.reason.stack ?? ""}`
: String(event.reason);
this.addEntry("error", `[UNHANDLED_REJECTION] ${reason}`);
this.flush();
} catch (_err) {
// Silent
}
if (previous) {
previous(event);
}
};
}
private hookAppState(): void {
AppState.addEventListener("change", (state: AppStateStatus) => {
try {
this.addEntry("info", `[APP_STATE] ${state}`);
if (state === "background" || state === "inactive") {
this.flush();
}
} catch (_err) {
// Silent
}
});
}
private async flush(): Promise<void> {
if (this.isFlushing || this.buffer.length === 0) return;
this.isFlushing = true;
const batch = this.buffer.splice(0);
this.inFlightBatch = batch;
try {
const result = await sendLogsToRemote(batch);
if (!result.success) {
await this.persistLogs(batch);
}
} catch (_err) {
await this.persistLogs(batch);
} finally {
this.inFlightBatch = [];
this.isFlushing = false;
}
}
private async persistLogs(logs: LogEntry[]): Promise<void> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
const existing: LogEntry[] = raw ? JSON.parse(raw) : [];
const merged = [...existing, ...logs].slice(-MAX_STORED_ENTRIES);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch (_err) {
// Silent
}
}
// Synchronously snapshot pending logs to disk. Called from the crash
// handlers before the runtime is torn down, so a startup crash is still
// recoverable on the next launch. Includes any in-flight flush batch,
// since `addEntry`'s auto-flush may have already emptied `buffer`.
private persistBufferSync(): void {
try {
const pending = [...this.inFlightBatch, ...this.buffer];
if (pending.length === 0) return;
const merged = [...this.readCrashLogsSync(), ...pending].slice(
-MAX_STORED_ENTRIES,
);
const file = new File(Paths.document, CRASH_FILE_NAME);
if (file.exists) {
file.delete();
}
file.create();
file.write(JSON.stringify(merged));
} catch (_err) {
// Silent — the logger must never crash the host app
}
}
private readCrashLogsSync(): LogEntry[] {
try {
const file = new File(Paths.document, CRASH_FILE_NAME);
if (!file.exists) return [];
const parsed: unknown = JSON.parse(file.textSync());
return Array.isArray(parsed) ? (parsed as LogEntry[]) : [];
} catch (_err) {
return [];
}
}
private clearCrashFileSync(): void {
try {
const file = new File(Paths.document, CRASH_FILE_NAME);
if (file.exists) {
file.delete();
}
} catch (_err) {
// Silent
}
}
private async restorePersistedLogs(): Promise<void> {
try {
const crashLogs = this.readCrashLogsSync();
if (crashLogs.length > 0) {
this.clearCrashFileSync();
}
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
await AsyncStorage.removeItem(STORAGE_KEY);
}
const storedLogs: LogEntry[] = raw ? JSON.parse(raw) : [];
const logs = [...crashLogs, ...storedLogs];
if (logs.length === 0) return;
const result = await sendLogsToRemote(logs);
if (!result.success) {
await this.persistLogs(logs);
}
} catch (_err) {
// Silent
}
}
}
export function initTestFlightLogger(): void {
try {
if (!isActive()) return;
if (instance) return;
instance = new TestFlightLogger();
instance.start();
} catch (_err) {
// Silent
}
}
export function getTestFlightLogger(): {
logError: (message: string) => void;
} | null {
try {
if (!isActive()) return null;
return instance;
} catch (_err) {
return null;
}
}

View File

@@ -1,82 +0,0 @@
{
"expo": {
"name": "Anything mobile app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
],
"package": "xyz.create.CreateExpoEnvironment"
},
"plugins": [
[
"expo-router",
{
"sitemap": false
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain"
}
],
"expo-audio",
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static"
}
}
],
[
"expo-video",
{
"supportsBackgroundPlayback": true,
"supportsPictureInPicture": true
}
],
"expo-font",
"expo-secure-store",
"expo-web-browser",
"@sentry/react-native/expo",
[
"react-native-google-mobile-ads",
{
"androidAppId": "ca-app-pub-3940256099942544~3347511713",
"iosAppId": "ca-app-pub-3940256099942544~1458002511"
}
]
],
"web": {
"bundler": "metro",
"favicon": "./assets/images/favicon.png"
},
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
}
}
}
}

Binary file not shown.

View File

@@ -1,6 +0,0 @@
module.exports = (api) => {
api.cache(true);
return {
presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]],
};
};

View File

@@ -1,40 +0,0 @@
{
"cli": {
"version": ">= 15.0.15",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
}
},
"preview": {
"distribution": "internal",
"env": {
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "app-bundle"
},
"env": {
"SENTRY_DISABLE_AUTO_UPLOAD": "true"
}
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "draft",
"changesNotSentForReview": false
}
}
}
}

View File

@@ -1,3 +0,0 @@
import App from './App';
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
body {
font-family: "Inter", sans-serif !important;
}

View File

@@ -1,33 +0,0 @@
declare module 'react-native/Libraries/Core/ExceptionsManager' {
export function handleException(err: Error, isFatal: boolean): void;
}
declare module 'react-native-safe-area-context/lib/commonjs' {
export const SafeAreaView: React.ComponentType<any>;
export const SafeAreaProvider: React.ComponentType<any>;
export const SafeAreaInsetsContext: React.Context<any>;
export const SafeAreaFrameContext: React.Context<any>;
export function useSafeAreaInsets(): { top: number; right: number; bottom: number; left: number };
export function useSafeAreaFrame(): { x: number; y: number; width: number; height: number };
export const initialWindowMetrics: any;
}
declare module 'react-native-web-refresh-control' {
export const RefreshControl: React.ComponentType<any>;
}
declare module 'react-native-web/dist/exports/ScrollView' {
const ScrollView: React.ComponentType<any>;
export default ScrollView;
}
declare module '@anythingai/app/screens/launcher-menu' {
const LauncherMenuContainer: React.ComponentType<any>;
export default LauncherMenuContainer;
}
declare module 'lodash' {
export function merge<T>(...args: T[]): T;
}
declare module '*.css' {}

View File

@@ -1,30 +0,0 @@
import ExceptionsManager from "react-native/Libraries/Core/ExceptionsManager";
if (__DEV__) {
ExceptionsManager.handleException = (_error, _isFatal) => {
// no-op
};
}
import "react-native-url-polyfill/auto";
import "./src/__create/polyfills";
global.Buffer = require("buffer").Buffer;
import "@expo/metro-runtime";
import { AppRegistry, LogBox } from "react-native";
import { initSentry } from "./__create/sentry";
import { initTestFlightLogger } from "./__create/testflight-logger";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import App from "./entrypoint";
initSentry();
initTestFlightLogger();
if (__DEV__ || process.env.EXPO_PUBLIC_CREATE_ENV === "DEVELOPMENT") {
LogBox.ignoreAllLogs();
LogBox.uninstall();
AppRegistry.setWrapperComponentProvider(() => ({ children }) => {
return <>{children}</>;
});
}
renderRootComponent(App);

View File

@@ -1,127 +0,0 @@
import '@expo/metro-runtime';
import { toPng } from 'html-to-image';
import React, { useEffect } from 'react';
import { renderRootComponent } from 'expo-router/build/renderRootComponent';
import { LoadSkiaWeb } from '@shopify/react-native-skia/lib/module/web';
import CreateApp from './App';
async function inlineGoogleFonts(): Promise<void> {
// Find all <link> elements that load Google Fonts CSS
const links = Array.from(document.querySelectorAll<HTMLLinkElement>(
'link[rel="stylesheet"][href*="fonts.googleapis.com"]'
));
for (const link of links) {
try {
const href = link.href;
const res = await fetch(href);
let cssText = await res.text();
// Ensure font URLs are absolute
cssText = cssText.replace(/url\(([^)]+)\)/g, (match, url) => {
const clean = url.replace(/["']/g, "");
if (clean.startsWith("http")) {
return `url(${clean})`;
}
return `url(${new URL(clean, href).toString()})`;
});
// Inject <style> with the CSS
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
} catch {
}
}
// Wait for all fonts to actually load
if ("fonts" in document) {
await document.fonts.ready;
}
}
const waitForScreenshotReady = async () => {
const images = Array.from(document.images);
await Promise.all([
inlineGoogleFonts(),
...images.map(
(img) =>
new Promise((resolve) => {
img.crossOrigin = "anonymous";
if (img.complete) {
resolve(true);
return;
}
img.onload = () => resolve(true);
img.onerror = () => resolve(true);
})
)
]);
// small buffer to ensure rendering is stable
await new Promise((resolve) => setTimeout(resolve, 250));
};
export const useHandleScreenshotRequest = () => {
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.data.type === "sandbox:web:screenshot:request") {
try {
await waitForScreenshotReady();
const width = window.innerWidth;
const height = window.innerHeight;
const app = document.querySelector<HTMLElement>('#root')
if (!app) {
throw new Error("Could not find app element");
}
const dataUrl = await toPng(app, {
cacheBust: true,
skipFonts: false,
width,
height,
style: {
width: `${width}px`,
height: `${height}px`,
margin: "0",
},
});
window.parent.postMessage(
{ type: "sandbox:web:screenshot:response", dataUrl },
"*"
);
} catch (error) {
window.parent.postMessage(
{
type: "sandbox:web:screenshot:error",
error: error instanceof Error ? error.message : String(error),
},
"*"
);
}
}
};
const listener = (event: MessageEvent) => { void handleMessage(event); };
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
}, []);
};
const CreateAppWithFonts = () => {
useHandleScreenshotRequest();
return <CreateApp />;
}
LoadSkiaWeb({
locateFile: (file: string) => `/${file}`,
}).then(async () => {
renderRootComponent(CreateAppWithFonts)
}).catch(() => {
renderRootComponent(CreateAppWithFonts)
});

View File

@@ -1,233 +0,0 @@
const { getDefaultConfig } = require("expo/metro-config");
const path = require("node:path");
const fs = require("node:fs");
const { FileStore } = require("metro-cache");
const { reportErrorToRemote } = require("./__create/report-error-to-remote");
const {
handleResolveRequestError,
VIRTUAL_ROOT,
VIRTUAL_ROOT_UNRESOLVED,
} = require("./__create/handle-resolve-request-error");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
config.maxWorkers = 6;
const WEB_ALIASES = {
"expo-secure-store": path.resolve(
__dirname,
"./polyfills/web/secureStore.web.ts",
),
"react-native-webview": path.resolve(
__dirname,
"./polyfills/web/webview.web.tsx",
),
"react-native-safe-area-context": path.resolve(
__dirname,
"./polyfills/web/safeAreaContext.web.tsx",
),
"react-native-maps": path.resolve(__dirname, "./polyfills/web/maps.web.tsx"),
"react-native-web/dist/exports/SafeAreaView": path.resolve(
__dirname,
"./polyfills/web/SafeAreaView.web.tsx",
),
"react-native-web/dist/exports/Alert": path.resolve(
__dirname,
"./polyfills/web/alerts.web.tsx",
),
"react-native-web/dist/exports/RefreshControl": path.resolve(
__dirname,
"./polyfills/web/refreshControl.web.tsx",
),
"expo-status-bar": path.resolve(
__dirname,
"./polyfills/web/statusBar.web.tsx",
),
"expo-location": path.resolve(__dirname, "./polyfills/web/location.web.ts"),
"./layouts/Tabs": path.resolve(__dirname, "./polyfills/web/tabbar.web.tsx"),
"expo-notifications": path.resolve(
__dirname,
"./polyfills/web/notifications.web.tsx",
),
"expo-contacts": path.resolve(__dirname, "./polyfills/web/contacts.web.ts"),
"expo-font": path.resolve(__dirname, "./polyfills/web/expo-font.web.ts"),
"react-native-google-mobile-ads": path.resolve(
__dirname,
"./polyfills/web/google-mobile-ads.web.tsx",
),
"react-native-web/dist/exports/ScrollView": path.resolve(
__dirname,
"./polyfills/web/scrollview.web.tsx",
),
"expo-haptics": path.resolve(__dirname, "./polyfills/web/haptics.web.ts"),
"expo-clipboard": path.resolve(__dirname, "./polyfills/web/clipboard.web.ts"),
"expo-camera": path.resolve(__dirname, "./polyfills/web/camera.web.tsx"),
"expo-image-picker": path.resolve(
__dirname,
"./polyfills/web/imagePicker.web.ts",
),
"expo-linking": path.resolve(__dirname, "./polyfills/web/linking.web.ts"),
"expo-web-browser": path.resolve(
__dirname,
"./polyfills/web/webBrowser.web.ts",
),
"expo-document-picker": path.resolve(
__dirname,
"./polyfills/web/documentPicker.web.ts",
),
};
const NATIVE_ALIASES = {
"./Libraries/Components/TextInput/TextInput": path.resolve(
__dirname,
"./polyfills/native/textinput.native.tsx",
),
"react-native-google-mobile-ads": path.resolve(
__dirname,
"./polyfills/native/google-mobile-ads.native.tsx",
),
};
// Aliases that only apply outside production. The real packages crash on
// import in Expo Go preview (their browser-mode shims pull in DOM-only code
// that throws on Hermes), which makes expo-router silently swallow the load
// error and warn "Route is missing the required default export" — leaving
// the app on a black/splash screen. EAS production builds keep the real
// modules so paid users hit the native SDKs as normal.
const DEV_ONLY_NATIVE_ALIASES = {
"react-native-purchases": path.resolve(
__dirname,
"./polyfills/native/react-native-purchases.native.tsx",
),
};
const SHARED_ALIASES = {
"expo-image": path.resolve(__dirname, "./polyfills/shared/expo-image.tsx"),
};
fs.mkdirSync(VIRTUAL_ROOT_UNRESOLVED, { recursive: true });
config.watchFolders = [
...config.watchFolders,
VIRTUAL_ROOT,
VIRTUAL_ROOT_UNRESOLVED,
];
// Add web-specific alias configuration through resolveRequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
try {
// Polyfills are not resolved by Metro
if (
context.originModulePath.startsWith(`${__dirname}/polyfills/native`) ||
context.originModulePath.startsWith(`${__dirname}/polyfills/web`) ||
context.originModulePath.startsWith(`${__dirname}/polyfills/shared`)
) {
return context.resolveRequest(context, moduleName, platform);
}
// Wildcard alias for Expo Google Fonts
if (
moduleName.startsWith("@expo-google-fonts/") &&
moduleName !== "@expo-google-fonts/dev"
) {
return context.resolveRequest(
context,
"@expo-google-fonts/dev",
platform,
);
}
// Resolve AnythingMenu to empty component in production
if (moduleName === "./src/__create/anything-menu") {
const isProduction = process.env.EXPO_PUBLIC_CREATE_ENV === "PRODUCTION";
if (isProduction) {
// Create empty component for production
const emptyComponentPath = path.resolve(
__dirname,
"./polyfills/shared/empty-component.tsx",
);
return context.resolveRequest(context, emptyComponentPath, platform);
}
}
if (SHARED_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
return context.resolveRequest(
context,
SHARED_ALIASES[moduleName],
platform,
);
}
if (platform === "web") {
// Only apply aliases if the module is one of our polyfills
if (WEB_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
return context.resolveRequest(
context,
WEB_ALIASES[moduleName],
platform,
);
}
return context.resolveRequest(context, moduleName, platform);
}
if (NATIVE_ALIASES[moduleName] && !moduleName.startsWith("./polyfills/")) {
return context.resolveRequest(
context,
NATIVE_ALIASES[moduleName],
platform,
);
}
if (
DEV_ONLY_NATIVE_ALIASES[moduleName] &&
!moduleName.startsWith("./polyfills/") &&
process.env.EXPO_PUBLIC_CREATE_ENV !== "PRODUCTION"
) {
return context.resolveRequest(
context,
DEV_ONLY_NATIVE_ALIASES[moduleName],
platform,
);
}
return context.resolveRequest(context, moduleName, platform);
} catch (error) {
return handleResolveRequestError({ error, context, platform, moduleName });
}
};
const cacheDir = path.join(__dirname, "caches");
config.cacheStores = () => [
new FileStore({
root: path.join(cacheDir, ".metro-cache"),
}),
];
config.resetCache = false;
config.fileMapCacheDirectory = cacheDir;
config.reporter = {
...config.reporter,
update: (event) => {
config.reporter?.update(event);
const reportableErrors = [
"error",
"bundling_error",
"cache_read_error",
"hmr_client_error",
"transformer_load_failed",
];
for (const errorType of reportableErrors) {
if (event.type === errorType) {
reportErrorToRemote({ error: event.error }).catch((_reportError) => {
// no-op
});
}
}
return event;
},
};
const originalGetTransformOptions = config.transformer.getTransformOptions;
config.transformer = {
...config.transformer,
getTransformOptions: async (entryPoints, options) => {
if (options.dev === false) {
fs.rmSync(cacheDir, { recursive: true, force: true });
fs.mkdirSync(cacheDir);
}
return await originalGetTransformOptions(entryPoints, options);
},
};
module.exports = config;

View File

@@ -1,132 +0,0 @@
{
"name": "mobile",
"version": "1.0.0",
"private": true,
"main": "index",
"scripts": {
"eas-build-pre-install": "corepack enable && corepack prepare yarn@4.12.0 --activate && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install"
},
"dependencies": {
"@anythingai/app": "0.1.96",
"@expo-google-fonts/dev": "0.4.7",
"@expo-google-fonts/inter": "0.4.2",
"@expo/vector-icons": "15.0.3",
"@gorhom/bottom-sheet": "5.2.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "patch:@react-native-community/netinfo@npm%3A11.4.1#~/.yarn/patches/@react-native-community+netinfo+11.4.1.patch",
"@react-native-community/slider": "5.0.1",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native-picker/picker": "2.11.1",
"@react-navigation/bottom-tabs": "7.4.8",
"@react-navigation/elements": "2.6.5",
"@react-navigation/native": "7.2.2",
"@react-navigation/native-stack": "7.3.27",
"@sentry/react-native": "8.11.1",
"@shopify/react-native-skia": "2.2.12",
"@tanstack/react-query": "5.72.2",
"@teovilla/react-native-web-maps": "0.9.5",
"@uploadcare/upload-client": "6.14.3",
"color2k": "2.0.3",
"date-fns": "4.1.0",
"expo": "54.0.34",
"expo-asset": "12.0.13",
"expo-audio": "1.1.1",
"expo-av": "16.0.8",
"expo-battery": "10.0.8",
"expo-blur": "15.0.8",
"expo-build-properties": "1.0.10",
"expo-calendar": "15.0.8",
"expo-camera": "17.0.10",
"expo-clipboard": "8.0.8",
"expo-constants": "18.0.13",
"expo-contacts": "15.0.11",
"expo-device": "8.0.10",
"expo-document-picker": "14.0.8",
"expo-file-system": "19.0.22",
"expo-font": "14.0.11",
"expo-gl": "16.0.10",
"expo-glass-effect": "~0.1.10",
"expo-haptics": "15.0.8",
"expo-image": "3.0.11",
"expo-image-manipulator": "14.0.8",
"expo-image-picker": "17.0.11",
"expo-linear-gradient": "15.0.8",
"expo-linking": "8.0.12",
"expo-location": "19.0.8",
"expo-modules-core": "3.0.30",
"expo-notifications": "0.32.17",
"expo-router": "patch:expo-router@npm%3A6.0.11#~/.yarn/patches/expo-router+6.0.11.patch",
"expo-secure-store": "15.0.8",
"expo-sensors": "15.0.8",
"expo-speech": "14.0.8",
"expo-splash-screen": "31.0.13",
"expo-status-bar": "3.0.9",
"expo-store-review": "patch:expo-store-review@npm%3A9.0.8#~/.yarn/patches/expo-store-review+9.0.8.patch",
"expo-symbols": "1.0.8",
"expo-system-ui": "6.0.9",
"expo-three": "8.0.0",
"expo-updates": "29.0.17",
"expo-video": "3.0.16",
"expo-web-browser": "15.0.11",
"html-to-image": "1.11.13",
"lodash": "^4.18.1",
"lucide-react-native": "0.525.0",
"moti": "0.30.0",
"papaparse": "5.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "patch:react-native@npm%3A0.81.4#~/.yarn/patches/react-native+0.81.4.patch",
"react-native-calendars": "https://codeload.github.com/craftworkco/react-native-calendars/tar.gz/ae19e2af74ecdb29d6117ca41fbf41977a10cc23",
"react-native-gesture-handler": "2.28.0",
"react-native-google-mobile-ads": "15.8.3",
"react-native-graph": "1.1.0",
"react-native-maps": "1.20.1",
"react-native-purchases": "patch:react-native-purchases@npm%3A9.6.1#~/.yarn/patches/react-native-purchases+9.6.1.patch",
"react-native-purchases-ui": "patch:react-native-purchases-ui@npm%3A9.6.1#~/.yarn/patches/react-native-purchases-ui+9.6.1.patch",
"react-native-reanimated": "4.1.1",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.6.0",
"react-native-screen-transitions": "^3.2.1",
"react-native-screens": "4.16.0",
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "2.0.0",
"react-native-web": "0.21.0",
"react-native-web-refresh-control": "patch:react-native-web-refresh-control@npm%3A1.1.2#~/.yarn/patches/react-native-web-refresh-control+1.1.2.patch",
"react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1",
"serialize-error": "12.0.0",
"sonner-native": "patch:sonner-native@npm%3A0.21.0#~/.yarn/patches/sonner-native+0.21.0.patch",
"three": "0.166.0",
"yup": "1.6.1",
"zod": "4.1.11",
"zustand": "5.0.3"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@expo/cli": "patch:@expo/cli@npm%3A54.0.1#~/.yarn/patches/@expo+cli+54.0.1.patch",
"@expo/metro-runtime": "patch:@expo/metro-runtime@npm%3A6.1.2#~/.yarn/patches/@expo+metro-runtime+6.1.2.patch",
"@tailwindcss/postcss": "4.1.18",
"@types/jest": "29.5.14",
"@types/react": "19.1.10",
"autoprefixer": "10.4.20",
"jest": "29.7.0",
"jest-expo": "54.0.17",
"postcss": "8.5.10",
"tailwind-scrollbar": "3.1.0",
"tailwindcss": "3",
"tailwindcss-animate": "1.0.7",
"typescript": "~5.9.2"
},
"overrides": {
"@react-navigation/bottom-tabs": "7.4.8",
"@react-navigation/core": "7.12.4",
"@react-navigation/elements": "2.6.5",
"@react-navigation/native": "7.2.2",
"@react-navigation/native-stack": "7.3.27",
"@react-navigation/routers": "7.5.1",
"@react-navigation/stack": "7.4.7"
},
"jest": {
"preset": "jest-expo"
}
}

View File

@@ -1,22 +0,0 @@
diff --git a/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm b/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
index 0000000..0000000 100644
--- a/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
+++ b/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm
@@ -432,14 +432,16 @@ void ObjCTurboModule::performVoidMethodInvocation(
TurboModulePerfLogger::asyncMethodCallExecutionStart(moduleName, methodName, asyncCallCounter);
}
@try {
[inv invokeWithTarget:strongModule];
} @catch (NSException *exception) {
- throw convertNSExceptionToJSError(runtime, exception, std::string{moduleName}, methodNameStr);
+ // Void methods are always async, re-throw instead of converting to
+ // JSError, same as the async branch in performMethodInvocation.
+ @throw exception;
} @finally {
[retainedObjectsForInvocation removeAllObjects];
}
if (shouldVoidMethodsExecuteSync_) {
TurboModulePerfLogger::syncMethodCallExecutionEnd(moduleName, methodName);
} else {

View File

@@ -1,424 +0,0 @@
import type React from "react";
import { Text, View, type ViewStyle } from "react-native";
// Stub for react-native-google-mobile-ads on web.
// Ads are native-only; these render visual placeholders so users can preview
// their layouts in Expo Go without the native module.
export const BannerAdSize = {
BANNER: "BANNER",
FULL_BANNER: "FULL_BANNER",
LARGE_BANNER: "LARGE_BANNER",
LEADERBOARD: "LEADERBOARD",
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
};
export const AdEventType = {
LOADED: "loaded",
ERROR: "error",
OPENED: "opened",
CLICKED: "clicked",
CLOSED: "closed",
};
export const RewardedAdEventType = {
LOADED: "loaded",
EARNED_REWARD: "earned_reward",
};
export const AdsConsentStatus = {
UNKNOWN: 0,
REQUIRED: 1,
NOT_REQUIRED: 2,
OBTAINED: 3,
};
export const AdsConsentDebugGeography = {
DISABLED: 0,
EEA: 1,
NOT_EEA: 2,
};
export const TestIds = {
BANNER: "ca-app-pub-3940256099942544/6300978111",
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
REWARDED: "ca-app-pub-3940256099942544/5224354917",
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
NATIVE: "ca-app-pub-3940256099942544/2247696110",
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
};
const PLACEHOLDER_BG = "#f5f5f5";
const PLACEHOLDER_BORDER = "#e0e0e0";
const PLACEHOLDER_TEXT = "#999999";
const AD_LABEL_BG = "#fbbc04";
const AD_LABEL_TEXT = "#1a1a1a";
const AdLabel = () => (
<View
style={{
backgroundColor: AD_LABEL_BG,
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 2,
}}
>
<Text
style={{
fontSize: 9,
fontWeight: "700",
color: AD_LABEL_TEXT,
lineHeight: 11,
}}
>
Ad
</Text>
</View>
);
const getBannerStyle = (size: string | undefined): ViewStyle => {
switch (size) {
case "FULL_BANNER":
return { width: 468, height: 60 };
case "LARGE_BANNER":
return { width: 320, height: 100 };
case "LEADERBOARD":
return { width: 728, height: 90 };
case "MEDIUM_RECTANGLE":
return { width: 300, height: 250 };
case "WIDE_SKYSCRAPER":
return { width: 160, height: 600 };
case "ADAPTIVE_BANNER":
case "ANCHORED_ADAPTIVE_BANNER":
return { width: "100%", height: 50 };
case "INLINE_ADAPTIVE_BANNER":
return { width: "100%", height: 100 };
default:
return { width: 320, height: 50 };
}
};
type BannerAdProps = {
size?: string;
unitId?: string;
onAdLoaded?: () => void;
onAdFailedToLoad?: (error: unknown) => void;
onAdOpened?: () => void;
onAdClosed?: () => void;
};
const BannerPlaceholder = ({
size,
label,
}: { size?: string; label: string }) => {
const dims = getBannerStyle(size);
return (
<View
style={{
...dims,
backgroundColor: PLACEHOLDER_BG,
borderWidth: 1,
borderColor: PLACEHOLDER_BORDER,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 6,
}}
>
<AdLabel />
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
</View>
);
};
export const BannerAd = ({ size }: BannerAdProps) => (
<BannerPlaceholder size={size} label="Banner Ad" />
);
export const GAMBannerAd = ({ size }: BannerAdProps) => (
<BannerPlaceholder size={size} label="Ad Manager Banner" />
);
type NativeAdViewProps = {
children?: React.ReactNode;
nativeAd?: unknown;
style?: ViewStyle | ViewStyle[];
};
const DefaultNativeAdContent = () => (
<View>
<View
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: PLACEHOLDER_BORDER,
marginRight: 10,
}}
/>
<View style={{ flex: 1 }}>
<View
style={{
height: 12,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
marginBottom: 6,
width: "70%",
}}
/>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
width: "40%",
}}
/>
</View>
</View>
<View
style={{
height: 140,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
marginBottom: 10,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
Native Ad Media
</Text>
</View>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
marginBottom: 6,
}}
/>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
width: "80%",
marginBottom: 12,
}}
/>
<View
style={{
alignSelf: "flex-start",
backgroundColor: "#1a73e8",
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
}}
>
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
Install
</Text>
</View>
</View>
);
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
<View
style={[
{
backgroundColor: PLACEHOLDER_BG,
borderWidth: 1,
borderColor: PLACEHOLDER_BORDER,
borderRadius: 8,
padding: 12,
position: "relative",
},
style as ViewStyle,
]}
>
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
<AdLabel />
</View>
{children ?? <DefaultNativeAdContent />}
</View>
);
export const NativeAsset = ({
children,
style,
}: {
children?: React.ReactNode;
assetType?: string;
style?: ViewStyle | ViewStyle[];
}) => <View style={style as ViewStyle}>{children}</View>;
export const NativeMediaView = ({
style,
}: { style?: ViewStyle | ViewStyle[] }) => (
<View
style={[
{
height: 180,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
},
style as ViewStyle,
]}
>
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
Ad Media (native only)
</Text>
</View>
);
export const NativeAd = {
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
headline: "Sample Ad Headline",
body: "Native ads only render on a real device.",
advertiser: "Sample Advertiser",
callToAction: "Install",
icon: null,
images: [],
starRating: null,
store: null,
price: null,
addAdEventListener: () => () => {},
removeAllListeners: () => {},
destroy: () => {},
}),
};
const createFullScreenAdStub = () => ({
loaded: false,
load: () => {},
show: () => Promise.resolve(),
addAdEventListener: () => () => {},
addAdEventsListener: () => () => {},
removeAllListeners: () => {},
});
export const InterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const RewardedAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const RewardedInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const AppOpenAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMRewardedAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMRewardedInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
const baseHookResult = {
isLoaded: false,
isOpened: false,
isClicked: false,
isClosed: false,
error: null as unknown,
load: () => {},
show: () => {},
};
export const useInterstitialAd = () => ({ ...baseHookResult });
export const useAppOpenAd = () => ({ ...baseHookResult });
export const useRewardedAd = () => ({
...baseHookResult,
isEarnedReward: false,
reward: null,
});
export const useRewardedInterstitialAd = () => ({
...baseHookResult,
isEarnedReward: false,
reward: null,
});
export const AdsConsent = {
requestInfoUpdate: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
isConsentFormAvailable: false,
}),
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
loadAndShowConsentFormIfRequired: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
}),
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
reset: () => {},
getConsentInfo: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
canRequestAds: true,
isConsentFormAvailable: false,
privacyOptionsRequirementStatus: "NOT_REQUIRED",
}),
getUserChoices: async () => ({}),
getTCString: async () => "",
getGdprApplies: async () => false,
getPurposeConsents: async () => "",
getPurposeLegitimateInterests: async () => "",
};
const mobileAdsInstance = {
initialize: async () => [],
setRequestConfiguration: async () => {},
openAdInspector: async () => {},
openDebugMenu: () => {},
setAppMuted: () => {},
setAppVolume: () => {},
};
const mobileAds = () => mobileAdsInstance;
const defaultExport = Object.assign(mobileAds, {
BannerAd,
GAMBannerAd,
BannerAdSize,
InterstitialAd,
RewardedAd,
RewardedInterstitialAd,
AppOpenAd,
GAMInterstitialAd,
GAMRewardedAd,
GAMRewardedInterstitialAd,
NativeAd,
NativeAdView,
NativeAsset,
NativeMediaView,
AdEventType,
RewardedAdEventType,
AdsConsent,
AdsConsentStatus,
AdsConsentDebugGeography,
TestIds,
});
export { mobileAds };
export default defaultExport;

View File

@@ -1,179 +0,0 @@
// Expo Go-safe stub for react-native-purchases.
//
// The real package's index pulls in @revenuecat/purchases-js-hybrid-mappings
// (a ~15k-line Svelte UMD bundle of browser-only code) which throws on
// module evaluation under Hermes in Expo Go preview. Even importing it from
// a hook that's never called crashes _layout.tsx, which makes expo-router
// silently swallow the throw and warn "Route \"./_layout.tsx\" is missing
// the required default export" — leaving the app stuck on a black/splash
// screen forever.
//
// This polyfill is wired up in metro.config.js for native platforms only
// when EXPO_PUBLIC_CREATE_ENV !== 'PRODUCTION'. Production EAS builds keep
// the real SDK, so paid users hit RevenueCat as normal.
const noopAsync = async () => undefined;
const LOG_LEVEL = {
VERBOSE: "VERBOSE",
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
SILENT: "SILENT",
};
const PRODUCT_CATEGORY = {
SUBSCRIPTION: "SUBSCRIPTION",
NON_SUBSCRIPTION: "NON_SUBSCRIPTION",
UNKNOWN: "UNKNOWN",
};
const PURCHASE_TYPE = {
INAPP: "inapp",
SUBS: "subs",
};
const PURCHASES_ARE_COMPLETED_BY_TYPE = {
REVENUECAT: "REVENUECAT",
MY_APP: "MY_APP",
};
const REFUND_REQUEST_STATUS = {
SUCCESS: "SUCCESS",
USER_CANCELLED: "USER_CANCELLED",
ERROR: "ERROR",
};
const BILLING_FEATURE = {
SUBSCRIPTIONS: "SUBSCRIPTIONS",
SUBSCRIPTIONS_UPDATE: "SUBSCRIPTIONS_UPDATE",
IN_APP_MESSAGING: "IN_APP_MESSAGING",
PRICE_CHANGE_CONFIRMATION: "PRICE_CHANGE_CONFIRMATION",
};
const STOREKIT_VERSION = {
DEFAULT: "DEFAULT",
STOREKIT_1: "STOREKIT_1",
STOREKIT_2: "STOREKIT_2",
};
const Purchases = {
configure: noopAsync,
setLogLevel: () => {},
setLogHandler: () => {},
addCustomerInfoUpdateListener: () => () => {},
removeCustomerInfoUpdateListener: () => {},
getOfferings: async () => ({ current: null, all: {} }),
getProducts: async () => [],
getCustomerInfo: async () => ({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
latestExpirationDate: null,
firstSeen: new Date().toISOString(),
originalAppUserId: "expo-go-preview",
requestDate: new Date().toISOString(),
allExpirationDates: {},
allPurchaseDates: {},
originalApplicationVersion: null,
originalPurchaseDate: null,
managementURL: null,
nonSubscriptionTransactions: [],
}),
purchasePackage: async () => {
const error: Error & { userCancelled?: boolean } = new Error(
"Purchases not available in Expo Go preview. Build a development build or run in TestFlight to test purchases.",
);
error.userCancelled = true;
throw error;
},
purchaseProduct: async () => {
const error: Error & { userCancelled?: boolean } = new Error(
"Purchases not available in Expo Go preview.",
);
error.userCancelled = true;
throw error;
},
restorePurchases: async () => ({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
latestExpirationDate: null,
firstSeen: new Date().toISOString(),
originalAppUserId: "expo-go-preview",
requestDate: new Date().toISOString(),
allExpirationDates: {},
allPurchaseDates: {},
originalApplicationVersion: null,
originalPurchaseDate: null,
managementURL: null,
nonSubscriptionTransactions: [],
}),
logIn: async (appUserID: string) => ({
customerInfo: {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
originalAppUserId: appUserID,
},
created: false,
}),
logOut: async () => ({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
originalAppUserId: "expo-go-preview",
}),
setAttributes: noopAsync,
setEmail: noopAsync,
setDisplayName: noopAsync,
setPhoneNumber: noopAsync,
setPushToken: noopAsync,
setAdjustID: noopAsync,
setAppsflyerID: noopAsync,
setFBAnonymousID: noopAsync,
setMparticleID: noopAsync,
setOnesignalID: noopAsync,
setAirshipChannelID: noopAsync,
setMediaSource: noopAsync,
setCampaign: noopAsync,
setAdGroup: noopAsync,
setAd: noopAsync,
setKeyword: noopAsync,
setCreative: noopAsync,
collectDeviceIdentifiers: () => {},
syncPurchases: noopAsync,
syncAttributesAndOfferingsIfNeeded: async () => ({ current: null, all: {} }),
enableAdServicesAttributionTokenCollection: () => {},
isAnonymous: async () => true,
checkTrialOrIntroductoryPriceEligibility: async () => ({}),
invalidateCustomerInfoCache: () => {},
presentCodeRedemptionSheet: () => {},
beginRefundRequestForActiveEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
beginRefundRequestForEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
beginRefundRequestForProduct: async () => REFUND_REQUEST_STATUS.ERROR,
showInAppMessages: noopAsync,
getPromotionalOffer: async () => null,
purchasePromotionalOffer: async () => {
const error: Error & { userCancelled?: boolean } = new Error(
"Purchases not available in Expo Go preview.",
);
error.userCancelled = true;
throw error;
},
canMakePayments: async () => false,
getAppUserID: async () => "expo-go-preview",
close: () => {},
configureInUITestMode: () => {},
};
export {
LOG_LEVEL,
PRODUCT_CATEGORY,
PURCHASE_TYPE,
PURCHASES_ARE_COMPLETED_BY_TYPE,
REFUND_REQUEST_STATUS,
BILLING_FEATURE,
STOREKIT_VERSION,
};
export default Purchases;

View File

@@ -1,16 +0,0 @@
import React from 'react';
import { TextInput as RNTextInput, type TextInputProps } from 'react-native';
const TextInput = React.forwardRef<RNTextInput, TextInputProps>((props, ref) => {
return (
<RNTextInput
ref={ref}
placeholderTextColor={props.placeholderTextColor || 'black'}
{...props}
/>
);
});
TextInput.displayName = 'TextInput';
export default TextInput;

View File

@@ -1 +0,0 @@
export default () => null;

View File

@@ -1,99 +0,0 @@
import type { ImageProps } from 'expo-image';
import * as ExpoImage from 'expo-image';
import { Buffer } from 'buffer';
import React, { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
function buildGridPlaceholder(w: number, h: number): string {
const size = Math.max(w, h);
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="895" height="895" rx="19" fill="#E9E7E7"/>
<g stroke="#C0C0C0" stroke-width="1.00975">
<line x1="447.505" y1="-23" x2="447.505" y2="901"/>
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505"/>
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068"/>
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504"/>
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061"/>
<line x1="282.495" y1="907" x2="282.495" y2="-30"/>
<line x1="611.495" y1="907" x2="611.495" y2="-30"/>
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061"/>
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53"/>
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612"/>
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643"/>
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772"/>
<circle cx="447.5" cy="441.5" r="163.995"/>
<circle cx="447.911" cy="447.911" r="237.407"/>
<circle cx="448" cy="442" r="384.495"/>
</g>
</svg>`;
const b64 = Buffer.from(svg).toString('base64');
return `data:image/svg+xml;base64,${b64}`;
}
type Src = ImageProps['source'];
function computeSourceKey(src: Src): string {
if (Array.isArray(src)) return src.map(computeSourceKey).join('|');
if (typeof src === 'number') return String(src); // require('./img.png')
if (typeof src === 'string') return src; // remote on web
if (src && typeof src === 'object' && 'uri' in src) return src.uri ?? '';
return '';
}
const WrappedImage = forwardRef<ExpoImage.Image, ImageProps>(function WrappedImage(props, ref) {
const [fallbackSource, setFallbackSource] = useState<Src | null>(null);
const source = props.source;
const onError = props.onError;
const style = props.style;
const currentKey = computeSourceKey(props.source);
const prevKeyRef = useRef(currentKey);
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
// parent really pointed to a different image: clear any old fallback
setFallbackSource(null);
prevKeyRef.current = currentKey;
}
}, [currentKey]);
const handleError: ImageProps['onError'] = useCallback(
(e: ExpoImage.ImageErrorEventData) => {
onError?.(e);
/* already swapped or dealing with a multisrc 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);
/* reexport everything that expo-image provides */
export * from 'expo-image';
export const Image = WrappedImage;
export default Image;

View File

@@ -1,38 +0,0 @@
import React, { forwardRef, type ReactNode } from 'react';
import { View } from 'react-native';
import { SafeAreaView as NativeSafeAreaView } from 'react-native-safe-area-context/lib/commonjs';
export {
initialWindowMetrics,
SafeAreaFrameContext,
SafeAreaInsetsContext,
SafeAreaProvider,
useSafeAreaFrame,
} from 'react-native-safe-area-context/lib/commonjs';
type Edge = 'top' | 'right' | 'bottom' | 'left';
type Edges = Edge[] | Record<Edge, 'off' | 'additive' | 'maximum'>;
interface SafeAreaViewProps {
children?: ReactNode;
edges?: Edges;
[key: string]: unknown;
}
export const SafeAreaView = forwardRef<View, SafeAreaViewProps>(
({ children, edges = ['top', 'right', 'bottom', 'left'] as Edges, ...rest }, forwardedRef) => {
const isTabletAndAbove = typeof window !== 'undefined' ? window.self !== window.top : true;
return (
<NativeSafeAreaView {...rest} edges={edges} ref={forwardedRef}>
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('top') || (!Array.isArray(edges) && (edges as Record<Edge, string>).top !== 'off')) && (
<View style={{ height: 64 }} />
)}
{children}
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('bottom') || (!Array.isArray(edges) && (edges as Record<Edge, string>).bottom !== 'off')) && (
<View style={{ height: 34 }} />
)}
</NativeSafeAreaView>
);
}
);
export default SafeAreaView;

View File

@@ -1,521 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
Modal,
StyleSheet,
Animated,
TouchableOpacity,
TextInput,
} from 'react-native';
type AlertButton = {
text: string;
onPress?: (value?: string | { login: string; password: string }) => void;
style: 'cancel' | 'destructive' | 'default';
};
type AlertOptions = {
userInterfaceStyle: string;
};
type AlertType = 'default' | 'plain-text' | 'secure-text' | 'login-password';
let globalAlertData = {
visible: false,
title: '',
message: '',
buttons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
userInterfaceStyle: 'light',
};
let setGlobalAlert: ((data: typeof globalAlertData) => void) | null = null;
let globalPromptData = {
visible: false,
title: '',
message: '',
callbackOrButtons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
type: 'default',
defaultValue: '',
keyboardType: 'default',
userInterfaceStyle: 'light',
};
let setGlobalPrompt: ((data: typeof globalPromptData) => void) | null = null;
const processButtons = (
buttons?: AlertButton[],
includeCancel = false
): AlertButton[] => {
let processedButtons =
buttons && buttons.length > 0
? buttons.map((button) => ({ ...button, onPress: button.onPress || (() => {}) }))
: includeCancel
? [
{ text: 'Cancel', onPress: () => {}, style: 'cancel' as const },
{ text: 'OK', onPress: () => {}, style: 'default' as const },
]
: [{ text: 'OK', onPress: () => {}, style: 'default' as const }];
// cancel button should always be the last button unless there are two buttons
if (processedButtons.length === 2) {
const cancelIndex = processedButtons.findIndex(
(btn) => btn.style === 'cancel'
);
if (cancelIndex === 1) {
processedButtons = [processedButtons[1], processedButtons[0]];
}
} else if (processedButtons.length >= 3) {
const cancelIndex = processedButtons.findLastIndex(
(btn) => btn.style === 'cancel'
);
if (cancelIndex !== -1 && cancelIndex !== processedButtons.length - 1) {
const cancelButton = processedButtons[cancelIndex];
const otherButtons = processedButtons.filter(
(_, index) => index !== cancelIndex
);
processedButtons = [...otherButtons, cancelButton];
}
}
return processedButtons;
};
const Alert = {
alert(
title: string,
message: string,
buttons?: AlertButton[],
userInterfaceStyle?: AlertOptions
) {
const processedButtons = processButtons(buttons);
globalAlertData = {
visible: true,
title: title,
message: message || '',
buttons: processedButtons.map((button) => ({
...button,
onPress: button.onPress || (() => {}),
})),
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
};
if (setGlobalAlert) {
setGlobalAlert({ ...globalAlertData });
}
},
prompt(
title: string,
message?: string,
callbackOrButtons?: AlertButton[],
type?: AlertType,
defaultValue?: string,
keyboardType?: string,
userInterfaceStyle?: AlertOptions
) {
const processedButtons = processButtons(callbackOrButtons, true);
globalPromptData = {
visible: true,
title: title,
message: message || '',
callbackOrButtons: processedButtons.map((button) => ({
...button,
onPress: button.onPress || (() => {}),
})),
type: type || 'plain-text',
defaultValue: defaultValue || '',
keyboardType: keyboardType || 'default',
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
};
if (setGlobalPrompt) {
setGlobalPrompt({ ...globalPromptData });
}
},
};
export const AlertModal = () => {
const [alertData, setAlertData] = useState(globalAlertData);
const [promptData, setPromptData] = useState(globalPromptData);
const [modalVisible, setModalVisible] = useState(false);
const [currentModalData, setCurrentModalData] = useState<{
visible: boolean;
title: string;
message: string;
buttons: {
text: string;
onPress: (value?: string | { login: string; password: string }) => void;
style: string;
}[];
userInterfaceStyle: string;
isPrompt: boolean;
type?: string;
defaultValue?: string;
keyboardType?: string;
} | null>(null);
const [inputValue, setInputValue] = useState('');
const [loginValue, setLoginValue] = useState('');
const scaleAnim = useRef(new Animated.Value(1.25)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const showModal = () => {
setModalVisible(true);
Animated.parallel([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
]).start();
};
if (promptData.visible) {
setCurrentModalData({
...promptData,
isPrompt: true,
buttons: promptData.callbackOrButtons,
});
showModal();
} else if (alertData.visible) {
setCurrentModalData({
...alertData,
buttons: alertData.buttons,
isPrompt: false,
});
showModal();
} else {
setCurrentModalData(null);
}
}, [
promptData.visible,
alertData.visible,
promptData,
alertData,
opacityAnim,
scaleAnim,
]);
const modalData = currentModalData || {
...alertData,
isPrompt: false,
buttons: alertData.buttons,
type: 'default',
defaultValue: '',
keyboardType: 'default',
};
useEffect(() => {
setGlobalAlert = setAlertData;
setGlobalPrompt = setPromptData;
return () => {
setGlobalAlert = null;
setGlobalPrompt = null;
};
}, []);
const closeModal = () => {
Animated.timing(opacityAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}).start(() => {
setModalVisible(false);
scaleAnim.setValue(1.25);
setInputValue('');
setLoginValue('');
globalAlertData = {
visible: false,
title: '',
message: '',
buttons: [],
userInterfaceStyle: 'light',
};
globalPromptData = {
visible: false,
title: '',
message: '',
callbackOrButtons: [],
type: 'default',
defaultValue: '',
keyboardType: 'default',
userInterfaceStyle: 'light',
};
setAlertData(globalAlertData);
setPromptData(globalPromptData);
setCurrentModalData(null);
});
};
const styles = styling(modalData.userInterfaceStyle);
return (
<Modal visible={modalVisible} transparent animationType="none">
<Animated.View style={[styles.container, { opacity: opacityAnim }]}>
<Animated.View
style={[
styles.content,
{ transform: [{ scale: scaleAnim }] },
modalData.userInterfaceStyle === 'dark'
? {
backgroundColor: 'rgba(0,0,0,0.65)',
}
: { backgroundColor: 'rgba(255, 255, 255, 0.75)' },
]}
>
<View style={styles.contentContainer}>
<Text
style={[
styles.title,
modalData.userInterfaceStyle === 'dark' && {
color: 'white',
},
]}
>
{modalData.title}
</Text>
{modalData.message ? (
<Text
style={[
styles.message,
modalData.userInterfaceStyle === 'dark' && {
color: 'white',
},
]}
>
{modalData.message}
</Text>
) : null}
{modalData?.isPrompt && modalData.type !== 'default' ? (
<View>
{modalData.type === 'login-password' ? (
<TextInput
style={[
styles.textInput,
styles.textInputTop,
modalData.userInterfaceStyle === 'dark'
? {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: 'white',
borderColor: 'rgba(255,255,255,0.3)',
}
: {
backgroundColor: 'rgba(255,255,255,0.9)',
color: 'black',
borderColor: 'rgba(0, 0, 0, 0.2)',
},
]}
value={loginValue}
onChangeText={setLoginValue}
placeholder="Login"
placeholderTextColor={
modalData.userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.5)'
: 'rgba(0,0,0,0.5)'
}
autoFocus
/>
) : null}
<TextInput
style={[
styles.textInput,
modalData.type === 'login-password' &&
styles.textInputBottom,
modalData.userInterfaceStyle === 'dark'
? {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: 'white',
borderColor: 'rgba(255,255,255,0.3)',
}
: {
backgroundColor: 'rgba(255,255,255,0.9)',
color: 'black',
borderColor: 'rgba(0, 0, 0, 0.2)',
},
]}
value={inputValue}
onChangeText={setInputValue}
placeholder={(() => {
switch (modalData.type) {
case 'plain-text':
return '';
case 'secure-text':
case 'login-password':
return 'Password';
default:
return '';
}
})()}
placeholderTextColor={
modalData.userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.5)'
: 'rgba(0,0,0,0.5)'
}
secureTextEntry={
modalData.type === 'secure-text' ||
modalData.type === 'login-password'
}
keyboardType={
modalData.keyboardType === 'numeric' ? 'numeric' : 'default'
}
autoFocus={modalData.type !== 'login-password'}
/>
</View>
) : null}
</View>
<View
style={[
modalData.buttons.length >= 3
? styles.buttonColumnContainer
: styles.buttonRowContainer,
modalData.buttons.length <= 2 && styles.buttonTopBorder,
]}
>
{modalData.buttons.map((button, index) => (
<TouchableOpacity
key={`${button.text}-${index}`}
onPress={() => {
if (modalData?.isPrompt) {
let valueToPass:
| string
| { login: string; password: string } = inputValue;
if (modalData.type === 'login-password') {
valueToPass = {
login: loginValue,
password: inputValue,
};
}
button.onPress(valueToPass);
} else {
button.onPress();
}
closeModal();
}}
style={[
styles.button,
modalData.buttons.length >= 3 && styles.buttonTopBorder,
modalData.buttons.length === 2 && { width: '50%' },
modalData.buttons.length <= 1 && { width: '100%' },
index === 0 &&
modalData.buttons.length === 2 && {
borderRightWidth: 1,
borderColor:
modalData.userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.2)'
: 'lightgray',
},
]}
>
<Text
style={[
styles.buttonText,
button.style === 'cancel' && { fontWeight: '600' },
button.style === 'destructive' && { color: 'red' },
]}
>
{button.text}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</Animated.View>
</Modal>
);
};
const styling = (userInterfaceStyle: string) =>
StyleSheet.create<Record<string, any>>({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.2)',
},
content: {
backdropFilter: 'blur(20px)' as any,
borderRadius: 12,
width: 244,
},
contentContainer: {
paddingVertical: 20,
paddingHorizontal: 12,
gap: 4,
},
title: {
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
message: {
fontSize: 12,
textAlign: 'center',
},
button: {
paddingVertical: 12,
},
buttonText: {
color: '#007AFF',
textAlign: 'center',
fontSize: 16,
},
textInput: {
borderWidth: 0.5,
borderColor: 'lightgray',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
marginTop: 16,
marginBottom: -8,
marginHorizontal: 12,
fontSize: 12,
outlineStyle: 'none' as any,
},
textInputTop: {
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
borderBottomWidth: 0,
marginBottom: 0,
},
textInputBottom: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
marginTop: 0,
},
buttonTopBorder: {
borderTopWidth: 0.5,
borderTopColor:
userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.2)'
: 'lightgray',
},
buttonRowContainer: {
flexDirection: 'row',
borderTopWidth: 0.5,
borderTopColor:
userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.2)'
: 'lightgray',
},
buttonColumnContainer: {
flexDirection: 'column',
},
buttonRightBorder: {
borderRightWidth: 0.5,
borderRightColor:
userInterfaceStyle === 'dark'
? 'rgba(255,255,255,0.2)'
: 'lightgray',
},
});
export default Alert;

View File

@@ -1,241 +0,0 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { View, Text, StyleSheet, type ViewStyle } from 'react-native';
export enum CameraType {
front = 'front',
back = 'back',
}
export enum FlashMode {
off = 'off',
on = 'on',
auto = 'auto',
torch = 'torch',
}
export enum CameraMode {
picture = 'picture',
video = 'video',
}
interface CameraViewProps {
style?: ViewStyle;
facing?: 'front' | 'back';
flash?: 'off' | 'on' | 'auto';
mode?: 'picture' | 'video';
zoom?: number;
enableTorch?: boolean;
onCameraReady?: () => void;
onMountError?: (event: { message: string }) => void;
children?: React.ReactNode;
}
interface CameraViewRef {
takePictureAsync: (options?: {
quality?: number;
base64?: boolean;
}) => Promise<{ uri: string; width: number; height: number; base64?: string }>;
recordAsync: (options?: {
maxDuration?: number;
}) => Promise<{ uri: string }>;
stopRecording: () => void;
}
export const CameraView = forwardRef<CameraViewRef, CameraViewProps>(
function CameraView(
{ style, facing = 'back', onCameraReady, onMountError, children },
ref
) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const [hasCamera, setHasCamera] = useState(true);
const startCamera = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facing === 'front' ? 'user' : 'environment',
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
onCameraReady?.();
} catch (err) {
setHasCamera(false);
onMountError?.({
message:
err instanceof Error ? err.message : 'Camera not available',
});
}
}, [facing, onCameraReady, onMountError]);
useEffect(() => {
void startCamera();
return () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
}, [startCamera]);
useImperativeHandle(ref, () => ({
takePictureAsync: async (options) => {
const video = videoRef.current;
if (!video || !streamRef.current) {
throw new Error('Camera is not ready');
}
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(video, 0, 0);
const quality = options?.quality ?? 0.85;
const dataUrl = canvas.toDataURL('image/jpeg', quality);
const result: {
uri: string;
width: number;
height: number;
base64?: string;
} = {
uri: dataUrl,
width: canvas.width,
height: canvas.height,
};
if (options?.base64) {
result.base64 = dataUrl.split(',')[1];
}
return result;
},
recordAsync: async () => {
throw new Error('Video recording is not supported in web preview');
},
stopRecording: () => {},
}));
if (!hasCamera) {
return (
<View style={[styles.container, style]}>
<View style={styles.placeholder}>
<Text style={styles.placeholderText}>Camera</Text>
<Text style={styles.placeholderSubtext}>
Not available in this browser
</Text>
</View>
{children}
</View>
);
}
return (
<View style={[styles.container, style]}>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{children}
</View>
);
}
);
const grantedPermission = {
status: 'granted' as const,
granted: true,
canAskAgain: true,
expires: 'never' as const,
};
const deniedPermission = {
status: 'denied' as const,
granted: false,
canAskAgain: true,
expires: 'never' as const,
};
export function useCameraPermissions(): [
{ status: string; granted: boolean; canAskAgain: boolean } | null,
() => Promise<{ status: string; granted: boolean; canAskAgain: boolean }>,
] {
const [permission, setPermission] = useState<{
status: string;
granted: boolean;
canAskAgain: boolean;
} | null>(null);
const requestPermission = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((t) => t.stop());
setPermission(grantedPermission);
return grantedPermission;
} catch {
setPermission(deniedPermission);
return deniedPermission;
}
}, []);
return [permission, requestPermission];
}
export async function requestCameraPermissionsAsync() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((t) => t.stop());
return grantedPermission;
} catch {
return deniedPermission;
}
}
export async function getCameraPermissionsAsync() {
try {
const result = await navigator.permissions.query({
name: 'camera' as PermissionName,
});
return result.state === 'granted' ? grantedPermission : deniedPermission;
} catch {
return deniedPermission;
}
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
backgroundColor: '#000',
},
placeholder: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#1a1a1a',
},
placeholderText: {
color: '#999',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
placeholderSubtext: {
color: '#666',
fontSize: 13,
},
});

View File

@@ -1,49 +0,0 @@
export async function getStringAsync(): Promise<string> {
try {
return await navigator.clipboard.readText();
} catch {
return '';
}
}
export async function setStringAsync(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export async function hasStringAsync(): Promise<boolean> {
try {
const text = await navigator.clipboard.readText();
return text.length > 0;
} catch {
return false;
}
}
export async function getImageAsync() {
return null;
}
export async function setImageAsync() {}
export async function hasImageAsync(): Promise<boolean> {
return false;
}
export function addClipboardListener(
_listener: (event: { contentTypes: string[] }) => void
) {
return { remove: () => {} };
}
export function removeClipboardListener(subscription: { remove: () => void }) {
subscription.remove();
}
export function isPlatformSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.clipboard;
}

View File

@@ -1,299 +0,0 @@
import type { ExistingContact, ContactQuery } from 'expo-contacts';
import { Fields, SortTypes } from 'expo-contacts/src/Contacts';
import Alert from './alerts.web';
import * as Notifications from 'expo-contacts';
const { PermissionStatus } = Notifications;
export { PermissionStatus, Fields, SortTypes };
const fakeContacts: ExistingContact[] = [
{
id: '1',
contactType: 'person',
name: 'John Doe',
firstName: 'John',
lastName: 'Doe',
phoneNumbers: [
{ number: '+1 (555) 123-4567', isPrimary: true, label: 'mobile' },
{ number: '+1 (555) 987-6543', isPrimary: false, label: 'home' },
],
emails: [
{ email: 'john.doe@example.com', isPrimary: true, label: 'work' },
{ email: 'john.personal@gmail.com', isPrimary: false, label: 'personal' },
],
addresses: [
{
street: '123 Main St',
city: 'New York',
region: 'NY',
postalCode: '10001',
country: 'USA',
label: 'home',
},
],
birthday: { day: 15, month: 5, year: 1990 },
note: 'Met at conference',
},
{
id: '2',
contactType: 'person',
name: 'Jane Smith',
firstName: 'Jane',
lastName: 'Smith',
phoneNumbers: [{ number: '+1 (555) 234-5678', isPrimary: true, label: 'mobile' }],
emails: [{ email: 'jane.smith@company.com', isPrimary: true, label: 'work' }],
addresses: [
{
street: '456 Oak Ave',
city: 'Los Angeles',
region: 'CA',
postalCode: '90210',
country: 'USA',
label: 'home',
},
],
birthday: { day: 3, month: 12, year: 1985 },
note: 'College friend',
},
{
id: '3',
contactType: 'person',
name: 'Bob Johnson',
firstName: 'Bob',
lastName: 'Johnson',
phoneNumbers: [{ number: '+1 (555) 345-6789', isPrimary: true, label: 'mobile' }],
emails: [{ email: 'bob.johnson@email.com', isPrimary: true, label: 'personal' }],
addresses: [],
birthday: { day: 22, month: 8, year: 1992 },
note: 'Neighbor',
},
{
id: '4',
contactType: 'person',
name: 'Alice Williams',
firstName: 'Alice',
lastName: 'Williams',
phoneNumbers: [
{ number: '+1 (555) 456-7890', isPrimary: true, label: 'mobile' },
{ number: '+1 (555) 111-2222', isPrimary: false, label: 'work' },
],
emails: [{ email: 'alice.williams@startup.com', isPrimary: true, label: 'work' }],
addresses: [
{
street: '789 Pine St',
city: 'San Francisco',
region: 'CA',
postalCode: '94102',
country: 'USA',
label: 'work',
},
],
birthday: { day: 10, month: 3, year: 1988 },
note: 'Business partner',
},
{
id: '5',
contactType: 'person',
name: 'Charlie Brown',
firstName: 'Charlie',
lastName: 'Brown',
phoneNumbers: [{ number: '+1 (555) 567-8901', isPrimary: true, label: 'mobile' }],
emails: [{ email: 'charlie.brown@gmail.com', isPrimary: true, label: 'personal' }],
addresses: [],
birthday: { day: 18, month: 11, year: 1995 },
note: 'Gym buddy',
},
];
let permissionStatus = {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
granted: false,
canAskAgain: true,
};
// since we polyfill fake contacts, we always return true
export const isAvailableAsync = async () => {
return true;
};
export const requestPermissionsAsync = async () => {
if (permissionStatus.status === PermissionStatus.GRANTED) {
return permissionStatus;
}
return new Promise((resolve) => {
Alert.alert(
'"Expo Go" Would Like to Access Your Contacts',
'Allow Expo projects to access your contacts',
[
{
text: "Don't Allow",
onPress: () => {
permissionStatus = {
status: PermissionStatus.DENIED,
expires: 'never',
granted: false,
canAskAgain: true,
};
resolve(permissionStatus);
},
style: 'default',
},
{
text: 'Continue',
onPress: () => {
permissionStatus = {
status: PermissionStatus.GRANTED,
expires: 'never',
granted: true,
canAskAgain: false,
};
resolve(permissionStatus);
},
style: 'default',
},
]
);
});
};
export const getPermissionsAsync = async () => {
return permissionStatus;
};
export const getContactsAsync = async (options: ContactQuery = {}) => {
const { sort = SortTypes.FirstName, pageSize, pageOffset } = options;
let contacts = [...fakeContacts];
if (sort === SortTypes.FirstName) {
contacts.sort((a, b) => (a.firstName || '').localeCompare(b.firstName || ''));
} else if (sort === SortTypes.LastName) {
contacts.sort((a, b) => (a.lastName || '').localeCompare(b.lastName || ''));
}
if (pageSize && pageOffset !== undefined) {
const startIndex = pageOffset * pageSize;
contacts = contacts.slice(startIndex, startIndex + pageSize);
}
return {
data: contacts,
hasNextPage: false,
hasPreviousPage: false,
total: fakeContacts.length,
};
};
export const getContactByIdAsync = async (id: string, _options: ContactQuery = {}) => {
const contact = fakeContacts.find((c) => c.id === id);
if (!contact) {
throw new Error(`Contact with id ${id} not found`);
}
return contact;
};
export const addContactAsync = async (contact: ExistingContact) => {
const newContact: ExistingContact = {
id: Date.now().toString(),
contactType: contact.contactType || 'person',
name: contact.name || '',
firstName: contact.firstName || '',
lastName: contact.lastName || '',
phoneNumbers: contact.phoneNumbers || [],
emails: contact.emails || [],
addresses: contact.addresses || [],
birthday: contact.birthday,
note: contact.note || '',
middleName: contact.middleName,
maidenName: contact.maidenName,
namePrefix: contact.namePrefix,
nameSuffix: contact.nameSuffix,
nickname: contact.nickname,
phoneticFirstName: contact.phoneticFirstName,
phoneticMiddleName: contact.phoneticMiddleName,
phoneticLastName: contact.phoneticLastName,
company: contact.company,
jobTitle: contact.jobTitle,
department: contact.department,
imageAvailable: contact.imageAvailable,
image: contact.image,
rawImage: contact.rawImage,
dates: contact.dates,
relationships: contact.relationships,
instantMessageAddresses: contact.instantMessageAddresses,
urlAddresses: contact.urlAddresses,
nonGregorianBirthday: contact.nonGregorianBirthday,
socialProfiles: contact.socialProfiles,
isFavorite: contact.isFavorite,
};
fakeContacts.push(newContact);
Alert.alert('Success', 'Contact added successfully!');
return newContact.id;
};
export const updateContactAsync = async (contact: ExistingContact) => {
const index = fakeContacts.findIndex((c) => c.id === contact.id);
if (index === -1) {
throw new Error(`Contact with id ${contact.id} not found`);
}
fakeContacts[index] = { ...fakeContacts[index], ...contact };
return contact.id;
};
export const removeContactAsync = async (contactId: string) => {
const index = fakeContacts.findIndex((c) => c.id === contactId);
if (index === -1) {
throw new Error(`Contact with id ${contactId} not found`);
}
fakeContacts.splice(index, 1);
setTimeout(() => {
Alert.alert('Success', 'Contact deleted successfully!');
}, 500);
return contactId;
};
const _createNoOpAsync = async () => {
Alert.alert('Not supported in the builder', 'Please use the Expo Go app to test this feature');
return { type: 'custom', data: null };
};
export const presentContactPickerAsync = async () => {
return _createNoOpAsync();
};
export const getGroupsAsync = async () => {
return _createNoOpAsync();
};
export const createGroupAsync = async () => {
return _createNoOpAsync();
};
export const removeGroupAsync = async () => {
return _createNoOpAsync();
};
export const updateGroupNameAsync = async () => {
return _createNoOpAsync();
};
export default {
Fields,
SortTypes,
PermissionStatus,
isAvailableAsync,
requestPermissionsAsync,
getPermissionsAsync,
getContactsAsync,
getContactByIdAsync,
addContactAsync,
updateContactAsync,
removeContactAsync,
presentContactPickerAsync,
getGroupsAsync,
createGroupAsync,
removeGroupAsync,
updateGroupNameAsync,
};

View File

@@ -1,94 +0,0 @@
interface DocumentPickerAsset {
name: string;
size: number | null;
uri: string;
mimeType: string | null;
}
interface DocumentPickerResult {
canceled: boolean;
assets: DocumentPickerAsset[];
output: null;
}
interface DocumentPickerOptions {
type?: string | string[];
copyToCacheDirectory?: boolean;
multiple?: boolean;
}
export async function getDocumentAsync(
options?: DocumentPickerOptions
): Promise<DocumentPickerResult> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = options?.multiple ?? false;
if (options?.type) {
const types = Array.isArray(options.type)
? options.type
: [options.type];
const filtered = types.filter((t) => t !== '*/*');
if (filtered.length > 0) {
input.accept = filtered.join(',');
}
}
input.style.display = 'none';
document.body.appendChild(input);
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
document.body.removeChild(input);
}
};
input.addEventListener('change', () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
resolve({ canceled: true, assets: [], output: null });
return;
}
const promises = Array.from(files).map(
(file) =>
new Promise<DocumentPickerAsset>((resolveAsset) => {
const reader = new FileReader();
reader.onload = () => {
resolveAsset({
name: file.name,
size: file.size,
uri: reader.result as string,
mimeType: file.type || null,
});
};
reader.readAsDataURL(file);
})
);
void Promise.all(promises).then((assets) => {
cleanup();
resolve({ canceled: false, assets, output: null });
});
});
window.addEventListener(
'focus',
() => {
setTimeout(() => {
if (!resolved) {
cleanup();
resolve({ canceled: true, assets: [], output: null });
}
}, 300);
},
{ once: true }
);
input.click();
});
}

View File

@@ -1,10 +0,0 @@
export * from 'expo-font';
export { useFonts } from 'expo-font';
export async function renderToImageAsync(): Promise<{
uri: string;
width: number;
height: number;
}> {
return { uri: '', width: 0, height: 0 };
}

View File

@@ -1,424 +0,0 @@
import type React from "react";
import { Text, View, type ViewStyle } from "react-native";
// Stub for react-native-google-mobile-ads on web.
// Ads are native-only; these render visual placeholders so users can preview
// their layouts in Expo Go without the native module.
export const BannerAdSize = {
BANNER: "BANNER",
FULL_BANNER: "FULL_BANNER",
LARGE_BANNER: "LARGE_BANNER",
LEADERBOARD: "LEADERBOARD",
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
};
export const AdEventType = {
LOADED: "loaded",
ERROR: "error",
OPENED: "opened",
CLICKED: "clicked",
CLOSED: "closed",
};
export const RewardedAdEventType = {
LOADED: "loaded",
EARNED_REWARD: "earned_reward",
};
export const AdsConsentStatus = {
UNKNOWN: 0,
REQUIRED: 1,
NOT_REQUIRED: 2,
OBTAINED: 3,
};
export const AdsConsentDebugGeography = {
DISABLED: 0,
EEA: 1,
NOT_EEA: 2,
};
export const TestIds = {
BANNER: "ca-app-pub-3940256099942544/6300978111",
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
REWARDED: "ca-app-pub-3940256099942544/5224354917",
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
NATIVE: "ca-app-pub-3940256099942544/2247696110",
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
};
const PLACEHOLDER_BG = "#f5f5f5";
const PLACEHOLDER_BORDER = "#e0e0e0";
const PLACEHOLDER_TEXT = "#999999";
const AD_LABEL_BG = "#fbbc04";
const AD_LABEL_TEXT = "#1a1a1a";
const AdLabel = () => (
<View
style={{
backgroundColor: AD_LABEL_BG,
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 2,
}}
>
<Text
style={{
fontSize: 9,
fontWeight: "700",
color: AD_LABEL_TEXT,
lineHeight: 11,
}}
>
Ad
</Text>
</View>
);
const getBannerStyle = (size: string | undefined): ViewStyle => {
switch (size) {
case "FULL_BANNER":
return { width: 468, height: 60 };
case "LARGE_BANNER":
return { width: 320, height: 100 };
case "LEADERBOARD":
return { width: 728, height: 90 };
case "MEDIUM_RECTANGLE":
return { width: 300, height: 250 };
case "WIDE_SKYSCRAPER":
return { width: 160, height: 600 };
case "ADAPTIVE_BANNER":
case "ANCHORED_ADAPTIVE_BANNER":
return { width: "100%", height: 50 };
case "INLINE_ADAPTIVE_BANNER":
return { width: "100%", height: 100 };
default:
return { width: 320, height: 50 };
}
};
type BannerAdProps = {
size?: string;
unitId?: string;
onAdLoaded?: () => void;
onAdFailedToLoad?: (error: unknown) => void;
onAdOpened?: () => void;
onAdClosed?: () => void;
};
const BannerPlaceholder = ({
size,
label,
}: { size?: string; label: string }) => {
const dims = getBannerStyle(size);
return (
<View
style={{
...dims,
backgroundColor: PLACEHOLDER_BG,
borderWidth: 1,
borderColor: PLACEHOLDER_BORDER,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 6,
}}
>
<AdLabel />
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
</View>
);
};
export const BannerAd = ({ size }: BannerAdProps) => (
<BannerPlaceholder size={size} label="Banner Ad" />
);
export const GAMBannerAd = ({ size }: BannerAdProps) => (
<BannerPlaceholder size={size} label="Ad Manager Banner" />
);
type NativeAdViewProps = {
children?: React.ReactNode;
nativeAd?: unknown;
style?: ViewStyle | ViewStyle[];
};
const DefaultNativeAdContent = () => (
<View>
<View
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: PLACEHOLDER_BORDER,
marginRight: 10,
}}
/>
<View style={{ flex: 1 }}>
<View
style={{
height: 12,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
marginBottom: 6,
width: "70%",
}}
/>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
width: "40%",
}}
/>
</View>
</View>
<View
style={{
height: 140,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
marginBottom: 10,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
Native Ad Media
</Text>
</View>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
marginBottom: 6,
}}
/>
<View
style={{
height: 10,
backgroundColor: "#ececec",
borderRadius: 4,
width: "80%",
marginBottom: 12,
}}
/>
<View
style={{
alignSelf: "flex-start",
backgroundColor: "#1a73e8",
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
}}
>
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
Install
</Text>
</View>
</View>
);
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
<View
style={[
{
backgroundColor: PLACEHOLDER_BG,
borderWidth: 1,
borderColor: PLACEHOLDER_BORDER,
borderRadius: 8,
padding: 12,
position: "relative",
},
style as ViewStyle,
]}
>
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
<AdLabel />
</View>
{children ?? <DefaultNativeAdContent />}
</View>
);
export const NativeAsset = ({
children,
style,
}: {
children?: React.ReactNode;
assetType?: string;
style?: ViewStyle | ViewStyle[];
}) => <View style={style as ViewStyle}>{children}</View>;
export const NativeMediaView = ({
style,
}: { style?: ViewStyle | ViewStyle[] }) => (
<View
style={[
{
height: 180,
backgroundColor: PLACEHOLDER_BORDER,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
},
style as ViewStyle,
]}
>
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
Ad Media (native only)
</Text>
</View>
);
export const NativeAd = {
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
headline: "Sample Ad Headline",
body: "Native ads only render on a real device.",
advertiser: "Sample Advertiser",
callToAction: "Install",
icon: null,
images: [],
starRating: null,
store: null,
price: null,
addAdEventListener: () => () => {},
removeAllListeners: () => {},
destroy: () => {},
}),
};
const createFullScreenAdStub = () => ({
loaded: false,
load: () => {},
show: () => Promise.resolve(),
addAdEventListener: () => () => {},
addAdEventsListener: () => () => {},
removeAllListeners: () => {},
});
export const InterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const RewardedAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const RewardedInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const AppOpenAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMRewardedAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
export const GAMRewardedInterstitialAd = {
createForAdRequest: () => createFullScreenAdStub(),
};
const baseHookResult = {
isLoaded: false,
isOpened: false,
isClicked: false,
isClosed: false,
error: null as unknown,
load: () => {},
show: () => {},
};
export const useInterstitialAd = () => ({ ...baseHookResult });
export const useAppOpenAd = () => ({ ...baseHookResult });
export const useRewardedAd = () => ({
...baseHookResult,
isEarnedReward: false,
reward: null,
});
export const useRewardedInterstitialAd = () => ({
...baseHookResult,
isEarnedReward: false,
reward: null,
});
export const AdsConsent = {
requestInfoUpdate: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
isConsentFormAvailable: false,
}),
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
loadAndShowConsentFormIfRequired: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
}),
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
reset: () => {},
getConsentInfo: async () => ({
status: AdsConsentStatus.NOT_REQUIRED,
canRequestAds: true,
isConsentFormAvailable: false,
privacyOptionsRequirementStatus: "NOT_REQUIRED",
}),
getUserChoices: async () => ({}),
getTCString: async () => "",
getGdprApplies: async () => false,
getPurposeConsents: async () => "",
getPurposeLegitimateInterests: async () => "",
};
const mobileAdsInstance = {
initialize: async () => [],
setRequestConfiguration: async () => {},
openAdInspector: async () => {},
openDebugMenu: () => {},
setAppMuted: () => {},
setAppVolume: () => {},
};
const mobileAds = () => mobileAdsInstance;
const defaultExport = Object.assign(mobileAds, {
BannerAd,
GAMBannerAd,
BannerAdSize,
InterstitialAd,
RewardedAd,
RewardedInterstitialAd,
AppOpenAd,
GAMInterstitialAd,
GAMRewardedAd,
GAMRewardedInterstitialAd,
NativeAd,
NativeAdView,
NativeAsset,
NativeMediaView,
AdEventType,
RewardedAdEventType,
AdsConsent,
AdsConsentStatus,
AdsConsentDebugGeography,
TestIds,
});
export { mobileAds };
export default defaultExport;

View File

@@ -1,61 +0,0 @@
export enum NotificationFeedbackType {
Success = 'success',
Warning = 'warning',
Error = 'error',
}
export enum ImpactFeedbackStyle {
Light = 'light',
Medium = 'medium',
Heavy = 'heavy',
Soft = 'soft',
Rigid = 'rigid',
}
const vibrationPatterns: Record<
NotificationFeedbackType | ImpactFeedbackStyle | 'selection',
VibratePattern
> = {
[NotificationFeedbackType.Success]: [40, 100, 40],
[NotificationFeedbackType.Warning]: [50, 100, 50],
[NotificationFeedbackType.Error]: [60, 100, 60, 100, 60],
[ImpactFeedbackStyle.Light]: [40],
[ImpactFeedbackStyle.Medium]: [50],
[ImpactFeedbackStyle.Heavy]: [60],
[ImpactFeedbackStyle.Soft]: [35],
[ImpactFeedbackStyle.Rigid]: [45],
selection: [50],
};
function isVibrationAvailable(): boolean {
return (
typeof window !== 'undefined' &&
'navigator' in window &&
'vibrate' in navigator
);
}
export const selectionAsync = async (): Promise<void> => {
if (!isVibrationAvailable()) {
return;
}
navigator.vibrate(vibrationPatterns.selection);
};
export const notificationAsync = async (
type: NotificationFeedbackType = NotificationFeedbackType.Success
): Promise<void> => {
if (!isVibrationAvailable()) {
return;
}
navigator.vibrate(vibrationPatterns[type]);
};
export const impactAsync = async (
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium
): Promise<void> => {
if (!isVibrationAvailable()) {
return;
}
navigator.vibrate(vibrationPatterns[style]);
};

View File

@@ -1,203 +0,0 @@
export enum MediaTypeOptions {
All = 'All',
Images = 'Images',
Videos = 'Videos',
}
export enum UIImagePickerPresentationStyle {
FULL_SCREEN = 0,
PAGE_SHEET = 1,
FORM_SHEET = 2,
CURRENT_CONTEXT = 3,
OVERFUL_SCREEN = 4,
POPOVER = 5,
AUTOMATIC = -2,
}
interface ImagePickerAsset {
uri: string;
width: number;
height: number;
type: 'image' | 'video' | undefined;
fileName: string | null;
fileSize: number | undefined;
mimeType: string | undefined;
}
interface ImagePickerResult {
canceled: boolean;
assets: ImagePickerAsset[];
}
interface ImagePickerOptions {
mediaTypes?: MediaTypeOptions;
allowsEditing?: boolean;
quality?: number;
allowsMultipleSelection?: boolean;
base64?: boolean;
}
function getAcceptString(mediaTypes?: MediaTypeOptions): string {
switch (mediaTypes) {
case MediaTypeOptions.Images:
return 'image/*';
case MediaTypeOptions.Videos:
return 'video/*';
default:
return 'image/*,video/*';
}
}
function pickFileViaInput(
accept: string,
capture: boolean,
multiple: boolean
): Promise<ImagePickerResult> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.multiple = multiple;
if (capture) input.setAttribute('capture', 'environment');
input.style.display = 'none';
document.body.appendChild(input);
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
document.body.removeChild(input);
}
};
input.addEventListener('change', () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
resolve({ canceled: true, assets: [] });
return;
}
const promises = Array.from(files).map(
(file) =>
new Promise<ImagePickerAsset>((resolveAsset) => {
const reader = new FileReader();
reader.onload = () => {
const uri = reader.result as string;
const img = new Image();
img.onload = () => {
resolveAsset({
uri,
width: img.naturalWidth,
height: img.naturalHeight,
type: file.type.startsWith('video') ? 'video' : 'image',
fileName: file.name,
fileSize: file.size,
mimeType: file.type || undefined,
});
};
img.onerror = () => {
resolveAsset({
uri,
width: 0,
height: 0,
type: file.type.startsWith('video') ? 'video' : 'image',
fileName: file.name,
fileSize: file.size,
mimeType: file.type || undefined,
});
};
img.src = uri;
};
reader.readAsDataURL(file);
})
);
void Promise.all(promises).then((assets) => {
cleanup();
resolve({ canceled: false, assets });
});
});
// Handle cancel (user closes the file dialog without selecting)
window.addEventListener(
'focus',
() => {
setTimeout(() => {
if (!resolved) {
cleanup();
resolve({ canceled: true, assets: [] });
}
}, 300);
},
{ once: true }
);
input.click();
});
}
export async function launchImageLibraryAsync(
options?: ImagePickerOptions
): Promise<ImagePickerResult> {
return pickFileViaInput(
getAcceptString(options?.mediaTypes),
false,
options?.allowsMultipleSelection ?? false
);
}
export async function launchCameraAsync(
options?: ImagePickerOptions
): Promise<ImagePickerResult> {
return pickFileViaInput(
getAcceptString(options?.mediaTypes),
true,
options?.allowsMultipleSelection ?? false
);
}
const grantedPermission = {
status: 'granted' as const,
granted: true,
canAskAgain: true,
expires: 'never' as const,
};
export async function requestMediaLibraryPermissionsAsync() {
return grantedPermission;
}
export async function getMediaLibraryPermissionsAsync() {
return grantedPermission;
}
export async function requestCameraPermissionsAsync() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((t) => t.stop());
return grantedPermission;
} catch {
return {
status: 'denied' as const,
granted: false,
canAskAgain: true,
expires: 'never' as const,
};
}
}
export async function getCameraPermissionsAsync() {
try {
const result = await navigator.permissions.query({
name: 'camera' as PermissionName,
});
const granted = result.state === 'granted';
return {
status: granted ? ('granted' as const) : ('denied' as const),
granted,
canAskAgain: result.state !== 'denied',
expires: 'never' as const,
};
} catch {
return grantedPermission;
}
}

View File

@@ -1,53 +0,0 @@
export async function openURL(url: string): Promise<true> {
window.open(url, '_blank');
return true;
}
export async function canOpenURL(_url: string): Promise<boolean> {
return true;
}
export function getInitialURL(): string {
return typeof window !== 'undefined' ? window.location.href : '';
}
export function createURL(
path: string,
namedParameters?: { queryParams?: Record<string, string> }
): string {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const url = new URL(path.startsWith('/') ? path : `/${path}`, base);
if (namedParameters?.queryParams) {
for (const [key, value] of Object.entries(namedParameters.queryParams)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
export function parse(url: string) {
const parsed = new URL(url);
const queryParams: Record<string, string> = {};
parsed.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
return {
path: parsed.pathname,
queryParams,
hostname: parsed.hostname,
scheme: parsed.protocol.replace(':', ''),
};
}
export function addEventListener(
_type: string,
handler: (event: { url: string }) => void
) {
const listener = () => handler({ url: window.location.href });
window.addEventListener('popstate', listener);
return { remove: () => window.removeEventListener('popstate', listener) };
}
export function useURL(): string | null {
return typeof window !== 'undefined' ? window.location.href : null;
}

View File

@@ -1,205 +0,0 @@
import type { LocationGeocodedAddress } from 'expo-location';
type Coords = { latitude: number; longitude: number };
export enum LocationAccuracy {
Lowest = 1,
Low = 2,
Balanced = 3,
High = 4,
Highest = 5,
BestForNavigation = 6,
}
export enum PermissionStatus {
DENIED = 'denied',
GRANTED = 'granted',
UNDETERMINED = 'undetermined',
}
interface LocationObject {
coords: {
latitude: number;
longitude: number;
altitude: number | null;
accuracy: number | null;
altitudeAccuracy: number | null;
heading: number | null;
speed: number | null;
};
timestamp: number;
}
interface LocationOptions {
accuracy?: LocationAccuracy;
distanceInterval?: number;
timeInterval?: number;
}
interface LocationSubscription {
remove: () => void;
}
function toLocationObject(position: GeolocationPosition): LocationObject {
return {
coords: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
accuracy: position.coords.accuracy,
altitudeAccuracy: position.coords.altitudeAccuracy,
heading: position.coords.heading,
speed: position.coords.speed,
},
timestamp: position.timestamp,
};
}
function toPermissionResponse(state: PermissionState) {
const granted = state === 'granted';
return {
status: granted
? PermissionStatus.GRANTED
: state === 'prompt'
? PermissionStatus.UNDETERMINED
: PermissionStatus.DENIED,
granted,
canAskAgain: state !== 'denied',
expires: 'never' as const,
};
}
export async function getCurrentPositionAsync(
options?: LocationOptions
): Promise<LocationObject> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not available in this browser'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve(toLocationObject(position)),
(error) => reject(new Error(error.message)),
{
enableHighAccuracy:
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
timeout: 10000,
}
);
});
}
export async function watchPositionAsync(
options: LocationOptions | null,
callback: (location: LocationObject) => void
): Promise<LocationSubscription> {
if (!navigator.geolocation) {
throw new Error('Geolocation is not available in this browser');
}
const watchId = navigator.geolocation.watchPosition(
(position) => callback(toLocationObject(position)),
() => {},
{
enableHighAccuracy:
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
}
);
return { remove: () => navigator.geolocation.clearWatch(watchId) };
}
export async function getLastKnownPositionAsync(): Promise<LocationObject | null> {
try {
return await getCurrentPositionAsync();
} catch {
return null;
}
}
export async function requestForegroundPermissionsAsync() {
if (!navigator.geolocation) {
return toPermissionResponse('denied');
}
return new Promise<ReturnType<typeof toPermissionResponse>>((resolve) => {
navigator.geolocation.getCurrentPosition(
() => resolve(toPermissionResponse('granted')),
() => resolve(toPermissionResponse('denied')),
{ timeout: 10000 }
);
});
}
export async function requestBackgroundPermissionsAsync() {
return toPermissionResponse('denied');
}
export async function getForegroundPermissionsAsync() {
try {
const result = await navigator.permissions.query({
name: 'geolocation',
});
return toPermissionResponse(result.state);
} catch {
return toPermissionResponse('prompt');
}
}
export async function getBackgroundPermissionsAsync() {
return toPermissionResponse('denied');
}
export async function geocodeAsync(
_address: string
): Promise<{ latitude: number; longitude: number; accuracy: number }[]> {
return [];
}
export async function reverseGeocodeAsync({
latitude,
longitude,
}: Coords): Promise<LocationGeocodedAddress[]> {
return [
{
city: 'Sample City',
street: 'Main Street',
district: 'Downtown',
region: 'Sample State',
postalCode: '12345',
country: 'Sample Country',
isoCountryCode: 'SC',
name: `Location at ${latitude.toFixed(4)}, ${longitude.toFixed(4)}`,
streetNumber: '123',
subregion: null,
timezone: null,
formattedAddress: null,
},
];
}
export async function hasServicesEnabledAsync(): Promise<boolean> {
return typeof navigator !== 'undefined' && !!navigator.geolocation;
}
export async function isBackgroundLocationAvailableAsync(): Promise<boolean> {
return false;
}
const NativeLocation = require('expo-location/build') as Record<string, unknown>;
module.exports = {
...NativeLocation,
LocationAccuracy,
PermissionStatus,
getCurrentPositionAsync,
watchPositionAsync,
getLastKnownPositionAsync,
requestForegroundPermissionsAsync,
requestBackgroundPermissionsAsync,
getForegroundPermissionsAsync,
getBackgroundPermissionsAsync,
geocodeAsync,
reverseGeocodeAsync,
hasServicesEnabledAsync,
isBackgroundLocationAvailableAsync,
};
module.exports.default = module.exports;

View File

@@ -1,51 +0,0 @@
import WebMapView, * as WebMaps from '@teovilla/react-native-web-maps';
import React from 'react';
export const PROVIDER_GOOGLE = 'google';
export const PROVIDER_DEFAULT = undefined;
const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY;
const MapView = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
return (
// @ts-expect-error — library default export is not typed as a valid JSX component
<WebMapView
ref={ref}
provider={PROVIDER_GOOGLE}
googleMapsApiKey={GOOGLE_MAPS_API_KEY}
{...props}
options={{
disableDefaultUI: true,
zoomControl: false,
streetViewControl: false,
mapTypeControl: false,
fullscreenControl: false,
rotateControl: false,
scaleControl: false,
keyboardShortcuts: false,
...(props.options as Record<string, unknown>),
}}
/>
);
});
Object.assign(MapView, {
...WebMaps,
PROVIDER_GOOGLE,
PROVIDER_DEFAULT,
});
// The library namespace export doesn't declare these members but they exist at runtime
const Maps = WebMaps as Record<string, unknown>;
export const Marker = Maps.Marker;
export const Callout = Maps.Callout;
export const Polyline = Maps.Polyline;
export const Polygon = Maps.Polygon;
export const Circle = Maps.Circle;
export const Overlay = Maps.Overlay;
export const Heatmap = Maps.Heatmap;
export const UrlTile = Maps.UrlTile;
export const WMSTile = Maps.WMSTile;
export const LocalTile = Maps.LocalTile;
export default MapView;

View File

@@ -1,80 +0,0 @@
import type {
NotificationRequest,
PermissionResponse,
} from 'expo-notifications/src/Notifications.types';
import type { NotificationHandler } from 'expo-notifications/src/NotificationsHandler';
import { toast } from 'sonner-native';
import * as Notifications from 'expo-notifications';
const { PermissionStatus } = Notifications;
const scheduledNotifications = new Map<
string,
{
timeoutId: ReturnType<typeof setTimeout>;
request: NotificationRequest;
}
>();
export const setNotificationHandler = (_handler: NotificationHandler | null): void => {
//no-op
};
export const requestPermissionsAsync = async (): Promise<PermissionResponse> => {
return {
status: PermissionStatus.GRANTED,
expires: 'never',
granted: true,
canAskAgain: true,
};
};
export const scheduleNotificationAsync = async (
notificationRequest: NotificationRequest
): Promise<string> => {
const { content, trigger: _trigger } = notificationRequest;
const { title, body } = content;
let message = '';
if (title && body) {
message = `${title}\n${body}`;
} else if (title) {
message = title;
} else if (body) {
message = `Expo Go\n${body}`;
} else {
return '';
}
const identifier = Math.random().toString(36).substr(2, 9);
const timeoutId = setTimeout(() => {
toast(message);
scheduledNotifications.delete(identifier);
}, 1000);
scheduledNotifications.set(identifier, {
timeoutId,
request: notificationRequest,
});
return identifier;
};
export const cancelAllScheduledNotificationsAsync = async (): Promise<void> => {
for (const { timeoutId } of scheduledNotifications.values()) {
clearTimeout(timeoutId);
}
scheduledNotifications.clear();
};
export const cancelScheduledNotificationAsync = async (identifier: string): Promise<void> => {
const scheduledNotification = scheduledNotifications.get(identifier);
if (scheduledNotification) {
clearTimeout(scheduledNotification.timeoutId);
scheduledNotifications.delete(identifier);
}
};
export const getAllScheduledNotificationsAsync = async (): Promise<NotificationRequest[]> => {
return Array.from(scheduledNotifications.values()).map(({ request }) => request);
};

View File

@@ -1,3 +0,0 @@
import { RefreshControl } from 'react-native-web-refresh-control';
export default RefreshControl;

View File

@@ -1,25 +0,0 @@
export {
SafeAreaProvider,
SafeAreaInsetsContext,
SafeAreaFrameContext,
useSafeAreaFrame,
initialWindowMetrics,
} from 'react-native-safe-area-context/lib/commonjs';
import { useSafeAreaInsets as useNativeSafeAreaInsets } from 'react-native-safe-area-context/lib/commonjs';
export { SafeAreaView } from './SafeAreaView.web';
export const useSafeAreaInsets = () => {
const isTabletAndAbove =
typeof window !== 'undefined' ? window.self !== window.top : true;
const insets = useNativeSafeAreaInsets();
if (isTabletAndAbove) {
return {
left: 0,
right: 0,
top: 64,
bottom: 34,
};
}
return insets;
};

View File

@@ -1,23 +0,0 @@
import React, { useMemo } from 'react';
import RNScrollView from 'react-native-web/dist/exports/ScrollView';
export const ScrollView = React.forwardRef((props: Record<string, any>, ref: React.Ref<any>) => {
const extendedStyle = useMemo(() => {
if (props.horizontal) {
return [{flexGrow: 0}, props.style]
}
return props.style
}, [props.horizontal, props.style])
return (
<RNScrollView
ref={ref}
{...props}
style={extendedStyle}
/>
);
});
ScrollView.displayName = 'ScrollView';
export default ScrollView;

View File

@@ -1,111 +0,0 @@
const VALUE_BYTES_LIMIT = 2048;
const KEYCHAIN_CONSTANTS = {
AFTER_FIRST_UNLOCK: 0,
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 1,
ALWAYS: 2,
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 3,
ALWAYS_THIS_DEVICE_ONLY: 4,
WHEN_UNLOCKED: 5,
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6,
};
export type KeychainAccessibilityConstant = number;
export const {
AFTER_FIRST_UNLOCK,
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
ALWAYS,
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
ALWAYS_THIS_DEVICE_ONLY,
WHEN_UNLOCKED,
WHEN_UNLOCKED_THIS_DEVICE_ONLY,
} = KEYCHAIN_CONSTANTS;
export type SecureStoreOptions = {
keychainService?: string;
requireAuthentication?: boolean;
authenticationPrompt?: string;
keychainAccessible?: KeychainAccessibilityConstant;
};
function isValidValue(value: string) {
if (typeof value !== 'string') {
return false;
}
if (new Blob([value]).size > VALUE_BYTES_LIMIT) {
// biome-ignore lint/suspicious/noConsole: useful for debugging
console.warn(
`Value being stored in SecureStore is larger than ${VALUE_BYTES_LIMIT} bytes and it may not be stored successfully.`
);
}
return true;
}
function getStorageKey(key: string): string {
return `_create_secure_store_${key}`;
}
export async function isAvailableAsync(): Promise<boolean> {
const testKey = '__SECURE_STORE_AVAILABILITY_TEST_KEY__';
try {
localStorage.setItem(testKey, 'test');
if (localStorage.getItem(testKey) !== 'test') {
return false;
}
localStorage.removeItem(testKey);
return localStorage.getItem(testKey) === null;
} catch {
return false;
}
}
export async function deleteItemAsync(
key: string,
_options: SecureStoreOptions = {}
): Promise<void> {
localStorage.removeItem(getStorageKey(key));
}
export async function getItemAsync(
key: string,
_options: SecureStoreOptions = {}
): Promise<string | null> {
return localStorage.getItem(getStorageKey(key));
}
export async function setItemAsync(
key: string,
value: string,
_options: SecureStoreOptions = {}
): Promise<void> {
if (!isValidValue(value)) {
throw new Error(
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
);
}
localStorage.setItem(getStorageKey(key), value);
}
export function setItem(
key: string,
value: string,
_options: SecureStoreOptions = {}
): void {
if (!isValidValue(value)) {
throw new Error(
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
);
}
localStorage.setItem(getStorageKey(key), value);
}
export function getItem(
key: string,
_options: SecureStoreOptions = {}
): string | null {
return localStorage.getItem(getStorageKey(key));
}
export function canUseBiometricAuthentication(): boolean {
return false;
}

View File

@@ -1,72 +0,0 @@
import React, { useEffect } from "react";
import { Appearance, useColorScheme } from "react-native";
import {
StatusBar as ExpoStatusBar,
type StatusBarStyle,
type StatusBarAnimation,
type StatusBarProps,
} from "expo-status-bar";
import * as ExpoSB from "expo-status-bar";
function postColorToParent(color: string) {
try {
if (typeof window !== "undefined" && "parent" in window) {
window.parent.postMessage(
{ type: "sandbox:mobile:statusbarcolor", color, timestamp: Date.now() },
"*"
);
}
} catch {
console.warn("Color was not sent to parent");
}
}
function styleToBarColor(
style: StatusBarStyle | "auto" | "inverted" = "auto",
colorScheme = Appearance.getColorScheme()
) {
const actual = colorScheme ?? "light";
let resolved:
| Exclude<StatusBarStyle, "auto" | "inverted">
| "light"
| "dark" = style as any;
if (style === "auto") resolved = actual === "light" ? "dark" : "light";
else if (style === "inverted")
resolved = actual === "light" ? "light" : "dark";
return resolved === "light" ? "#FFFFFF" : "#000000";
}
export const StatusBar = React.forwardRef<any, StatusBarProps>(
function StatusBar({ style = "auto", ...props }, _ref) {
const colorScheme = useColorScheme();
useEffect(() => {
postColorToParent(styleToBarColor(style, colorScheme));
}, [style, colorScheme]);
return <ExpoStatusBar style={style} {...props} />;
}
);
export const setStatusBarStyle = (style: StatusBarStyle, animated?: boolean) =>
ExpoSB.setStatusBarStyle(style, animated);
export const setStatusBarHidden = (
hidden: boolean,
animation?: StatusBarAnimation
) => ExpoSB.setStatusBarHidden(hidden, animation);
export const setStatusBarBackgroundColor = (
backgroundColor: string,
animated?: boolean
) => ExpoSB.setStatusBarBackgroundColor(backgroundColor as any, animated);
export const setStatusBarNetworkActivityIndicatorVisible = (visible: boolean) =>
ExpoSB.setStatusBarNetworkActivityIndicatorVisible(visible);
export const setStatusBarTranslucent = (translucent: boolean) =>
ExpoSB.setStatusBarTranslucent(translucent);
export type { StatusBarStyle, StatusBarAnimation, StatusBarProps };

View File

@@ -1,24 +0,0 @@
import { Tabs as ExpoTabs } from 'expo-router/build/layouts/Tabs';
import { merge } from 'lodash';
import { forwardRef } from 'react';
import { Platform } from 'react-native';
export const BASE_TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 49 : 56;
export const Tabs = forwardRef((props: any, ref: any) => {
const isInIframe = typeof window !== 'undefined' ? window.self !== window.top : false;
const height = props.screenOptions.tabBarStyle?.height || (BASE_TAB_BAR_HEIGHT + (isInIframe ? 34 : 0));
return (
<ExpoTabs
{...props}
screenOptions={merge(props.screenOptions, {
tabBarStyle: merge(props.screenOptions.tabBarStyle, { height }),
})}
ref={ref}
/>
);
});
(Tabs as any).Screen = ExpoTabs.Screen;
export default Tabs;

View File

@@ -1,56 +0,0 @@
export enum WebBrowserResultType {
CANCEL = 'cancel',
DISMISS = 'dismiss',
OPENED = 'opened',
LOCKED = 'locked',
}
interface WebBrowserResult {
type: WebBrowserResultType;
}
let _openWindow: Window | null = null;
export async function openBrowserAsync(
url: string,
_options?: {
toolbarColor?: string;
controlsColor?: string;
secondaryToolbarColor?: string;
enableBarCollapsing?: boolean;
showTitle?: boolean;
enableDefaultShareMenuItem?: boolean;
windowName?: string;
windowFeatures?: string;
}
): Promise<WebBrowserResult> {
_openWindow = window.open(url, '_blank');
return { type: WebBrowserResultType.OPENED };
}
export async function openAuthSessionAsync(
url: string,
_redirectUrl?: string,
_options?: { showInRecents?: boolean }
): Promise<WebBrowserResult & { url?: string }> {
const authWindow = window.open(url, '_blank');
if (!authWindow) {
return { type: WebBrowserResultType.CANCEL };
}
return { type: WebBrowserResultType.OPENED };
}
export function dismissBrowser(): void {
if (_openWindow && !_openWindow.closed) {
_openWindow.close();
_openWindow = null;
}
}
export async function warmUpAsync(): Promise<void> {}
export async function coolDownAsync(): Promise<void> {}
export async function mayInitWithUrlAsync(
_url: string
): Promise<{ servicePackage: string | null }> {
return { servicePackage: null };
}

View File

@@ -1,109 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
type Props = {
source: { uri?: string; html?: string; headers?: Record<string, string> };
style?: StyleProp<ViewStyle>;
injectedJavaScript?: string;
onMessage?: (ev: { nativeEvent: { data: string } }) => void;
onLoadStart?: () => void;
onLoad?: () => void;
onLoadEnd?: () => void;
onError?: (syntheticEvent: {
nativeEvent: { code: number; description: string };
}) => void;
onNavigationStateChange?: (navState: {
url: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
}) => void;
onShouldStartLoadWithRequest?: (event: { url: string }) => boolean;
scrollEnabled?: boolean;
bounces?: boolean;
};
/**
* Web-based implementation of React Native WebView using iframe
*/
export const WebView = forwardRef((props: Props, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const {
source,
style,
injectedJavaScript,
onMessage,
onLoadStart,
onLoad,
onLoadEnd,
onNavigationStateChange,
} = props;
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
onMessage?.({ nativeEvent: { data: event.data } });
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onMessage]);
// Imperative handle to match RN WebView API
useImperativeHandle(ref, () => ({
injectJavaScript: (js: string) => {
iframeRef.current?.contentWindow?.postMessage(js, '*');
},
goBack: () => {
iframeRef.current?.contentWindow?.history.back();
},
goForward: () => {
iframeRef.current?.contentWindow?.history.forward();
},
reload: () => {
iframeRef.current?.contentWindow?.location.reload();
},
stopLoading: () => {
// Not directly possible with iframe
},
}));
const src = source.html
? `data:text/html;charset=utf-8,${encodeURIComponent(source.html)}`
: source.uri;
return (
<iframe
ref={iframeRef}
src={src}
style={{
border: 'none',
width: '100%',
height: '100%',
overflow: props.scrollEnabled === false ? 'hidden' : 'auto',
...(style as Record<string, unknown>),
}}
allow="third-party-cookies"
onLoad={(e) => {
onLoadStart?.();
onLoad?.();
onLoadEnd?.();
if (injectedJavaScript) {
iframeRef.current?.contentWindow?.postMessage(
injectedJavaScript,
'*'
);
}
const win = e.currentTarget.contentWindow;
if (win) {
onNavigationStateChange?.({
url: win.location.href,
loading: false,
canGoBack: win.history.length > 1,
canGoForward: win.history.length > 1,
});
}
}}
/>
);
});
export default WebView;

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

View File

@@ -1,105 +0,0 @@
import React, { Component, type ReactNode } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
function postErrorToParent(error: Error) {
try {
if (typeof window !== 'undefined' && window.parent !== window) {
window.parent.postMessage(
{
type: 'sandbox:error:detected',
error: {
message: error.message,
name: error.name || 'Error',
stack: error.stack || '',
},
},
'*'
);
}
} catch {}
}
function postErrorResolvedToParent() {
try {
if (typeof window !== 'undefined' && window.parent !== window) {
window.parent.postMessage({ type: 'sandbox:error:resolved' }, '*');
}
} catch {}
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
postErrorToParent(error);
}
render() {
if (this.state.hasError) {
return (
<View style={styles.container}>
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>
{this.state.error?.message ?? 'An unexpected error occurred'}
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => {
this.setState({ hasError: false, error: null });
postErrorResolvedToParent();
}}
>
<Text style={styles.buttonText}>Try again</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: '#fff',
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#18191B',
marginBottom: 8,
},
message: {
fontSize: 14,
color: '#959697',
textAlign: 'center',
marginBottom: 24,
},
button: {
backgroundColor: '#18191B',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});

View File

@@ -1,84 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { usePathname } from "expo-router";
import { useEffect } from "react";
import { Platform } from "react-native";
const VISITOR_ID_KEY = "anything_analytics_visitor_id";
// Mirror the gating used by Sentry / the TestFlight logger: only emit from
// real (production) builds, never from the in-builder dev runtime.
function isActive(): boolean {
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
}
function generateVisitorId(): string {
const rand = () => Math.random().toString(36).slice(2);
return `${rand()}${rand()}`.slice(0, 32);
}
let visitorIdPromise: Promise<string> | null = null;
// Stable, anonymous, per-install id. Not a secret, so AsyncStorage (not the
// keychain) is the right home. Generated once and reused for the install.
function getVisitorId(): Promise<string> {
if (!visitorIdPromise) {
visitorIdPromise = (async () => {
try {
const existing = await AsyncStorage.getItem(VISITOR_ID_KEY);
if (existing) return existing;
const created = generateVisitorId();
await AsyncStorage.setItem(VISITOR_ID_KEY, created);
return created;
} catch {
// If persistence fails, fall back to a session-scoped id so the
// current run still attributes its views to one visitor.
return generateVisitorId();
}
})();
}
return visitorIdPromise;
}
// Records one screen view per route change. The endpoint enforces the global
// flag and the project's analytics opt-in, dropping events (204) when off, so
// this always fires and the server decides whether to keep it.
export function ScreenViewTracker() {
const pathname = usePathname();
useEffect(() => {
if (!isActive()) return;
const endpoint = process.env.EXPO_PUBLIC_ANALYTICS_ENDPOINT;
const host = process.env.EXPO_PUBLIC_HOST;
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
if (!endpoint || !host || !projectGroupId || !pathname) return;
let cancelled = false;
void (async () => {
try {
const visitorId = await getVisitorId();
if (cancelled) return;
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
d: host,
p: pathname,
pgid: projectGroupId,
vid: visitorId,
os: Platform.OS,
dt: "mobile",
}),
});
} catch {
// Analytics must never crash or block the host app.
}
})();
return () => {
cancelled = true;
};
}, [pathname]);
return null;
}

View File

@@ -1,19 +0,0 @@
import LauncherMenuContainer from '@anythingai/app/screens/launcher-menu';
import React from 'react';
import { StyleSheet, View } from 'react-native';
const isExpoGo = globalThis.expo?.modules?.ExpoGo;
export default () => {
if (isExpoGo) {
return null;
}
return (
<View
style={{ ...StyleSheet.absoluteFillObject, zIndex: 9999 }}
pointerEvents="box-none"
>
<LauncherMenuContainer />
</View>
);
};

View File

@@ -1,586 +0,0 @@
import type React from "react";
import { useCallback, useEffect, useMemo, memo, useRef, useReducer } from "react";
import {
StyleSheet,
Text,
TouchableOpacity,
View,
PanResponder,
Platform,
useWindowDimensions,
} from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
interpolate,
withTiming,
Easing,
} from "react-native-reanimated";
import {
SafeAreaProvider,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import Svg, {
Path,
Rect,
Mask,
Circle,
G,
Defs,
ClipPath,
Line,
} from "react-native-svg";
import { NativeModule, requireNativeModule } from "expo-modules-core";
import { MotiView } from "moti";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { WebView } from "react-native-webview";
declare class AnythingLauncherModule extends NativeModule {
open(url: string): Promise<void>;
reset(): Promise<void>;
reload(): Promise<void>;
isWeb(): Promise<boolean>;
}
const TINT_DURATION_MS = 3000;
const CIRCLE_DIAMETER = 80;
const GAP = 16;
const ICON_SIZE = 18;
const getWebAppUrl = () => {
return process.env.EXPO_PUBLIC_APP_URL ?? "";
};
const isAnythingApp =
Platform.OS !== "web" &&
process.env.EXPO_PUBLIC_IS_ANYTHING_APP === JSON.stringify(true);
const AnythingLauncher = isAnythingApp
? requireNativeModule<AnythingLauncherModule>("AnythingLauncherModule")
: null;
const RefreshIcon = memo(() => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M1.5 7.5s1.504-2.049 2.725-3.271a6.75 6.75 0 11-1.712 6.646M1.5 7.5V3m0 4.5H6"
stroke="#7E7F80"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const CloseIcon = memo(() => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M2.25 15.75l13.5-13.5M15.75 15.75L2.25 2.25"
stroke="#7E7F80"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const MobileViewIcon = memo(({ color }: { color: string }) => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M11.8125 1.5H6.1875C5.15197 1.5 4.3125 2.33947 4.3125 3.375V14.625C4.3125 15.6605 5.15197 16.5 6.1875 16.5H11.8125C12.848 16.5 13.6875 15.6605 13.6875 14.625V3.375C13.6875 2.33947 12.848 1.5 11.8125 1.5Z"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Line
x1={7.89575}
y1={13.3832}
x2={10.104}
y2={13.3832}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const WebViewIcon = memo(({ color }: { color: string }) => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<G clipPath="url(#clip0_340_2754)">
<Path
d="M15 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V12C1.5 12.8284 2.17157 13.5 3 13.5H15C15.8284 13.5 16.5 12.8284 16.5 12V3C16.5 2.17157 15.8284 1.5 15 1.5Z"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M9 13.5V16.5"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M6 16.5H12"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</G>
<Defs>
<ClipPath id="clip0_340_2754">
<Rect width={18} height={18} fill="white" />
</ClipPath>
</Defs>
</Svg>
);
});
const ActiveDot = memo(() => {
return (
<View
style={{
backgroundColor: "#000",
borderRadius: 50,
width: 4,
height: 4,
position: "absolute",
bottom: -8,
}}
/>
);
});
const InstructionsOverlay = memo(
({
showTint,
width,
height,
}: {
showTint: boolean;
width: number;
height: number;
}) => {
const r = CIRCLE_DIAMETER / 2;
const totalWidth = CIRCLE_DIAMETER * 2 + GAP;
const left = (width - totalWidth) / 2;
const cx1 = left + r;
const cx2 = cx1 + CIRCLE_DIAMETER + GAP;
const cy = height / 2 + 64;
return (
<>
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: showTint ? 1 : 0 }}
transition={{ type: "timing", duration: 350 }}
style={menuStyles.holdTwoFingersTextContainer}
>
<Text style={menuStyles.holdTwoFingersText}>
Hold with 2 fingers for menu
</Text>
</MotiView>
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: showTint ? 1 : 0 }}
transition={{ type: "timing", duration: 350 }}
style={StyleSheet.absoluteFill}
>
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Mask id="holes">
<Rect x="0" y="0" width={width} height={height} fill="white" />
<Circle cx={cx1} cy={cy} r={r} fill="black" />
<Circle cx={cx2} cy={cy} r={r} fill="black" />
</Mask>
<Rect
x="0"
y="0"
width={width}
height={height}
fill="black"
opacity={0.8}
mask="url(#holes)"
/>
</Svg>
</MotiView>
</>
);
}
);
type State = {
isLoading: boolean;
showTint: boolean;
showWebView: boolean;
}
type Action = { type: 'INITIALIZE', payload: { showWebView: boolean, showTint: boolean } } | { type: 'TOGGLE_WEB_VIEW' } | { type: 'HIDE_TINT' }
const initialState: State = { isLoading: true, showTint: false, showWebView: false };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INITIALIZE':
return { ...state, ...action.payload, isLoading: false };
case 'TOGGLE_WEB_VIEW':
return { ...state, showWebView: !state.showWebView };
case 'HIDE_TINT':
return { ...state, showTint: false };
default:
return state;
}
}
const AnythingMenu = isAnythingApp
? ({ children }: { children: React.ReactNode }) => {
const insets = useSafeAreaInsets();
const [state, dispatch] = useReducer(reducer, initialState);
const { width, height } = useWindowDimensions();
useEffect(() => {
if (!AnythingLauncher) {
throw new Error("AnythingLauncher is not available");
}
if (state.isLoading) {
Promise.all([
AnythingLauncher.isWeb(),
AsyncStorage.getItem("hasSeenOnboarding"),
]).then(([isWeb, hasSeenOnboarding]) => {
dispatch({ type: 'INITIALIZE', payload: { showWebView: Boolean(isWeb), showTint: hasSeenOnboarding !== 'true' } });
}).catch(() => {
dispatch({ type: 'INITIALIZE', payload: { showWebView: false, showTint: false } });
});
}
}, [state.isLoading]);
useEffect(() => {
if (!state.isLoading && state.showTint) {
const timeout = setTimeout(() => {
void AsyncStorage.setItem("hasSeenOnboarding", "true");
dispatch({ type: 'HIDE_TINT' });
}, TINT_DURATION_MS);
return () => clearTimeout(timeout);
}
}, [state.isLoading, state.showTint])
const menuProgress = useSharedValue(0);
const hideMenuOffset = -(44 + 36 + insets.top + 10);
const exitApp = useCallback(() => {
void AnythingLauncher?.reset();
}, []);
const reloadApp = useCallback(() => {
void AnythingLauncher?.reload();
}, []);
const toggleWebView = useCallback(() => {
dispatch({ type: 'TOGGLE_WEB_VIEW' });
}, []);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(menuProgress.value, [0, 1], [1, 0.9]);
const shadowOpacity = interpolate(menuProgress.value, [0, 1], [0, 0.4]);
const elevation = interpolate(menuProgress.value, [0, 1], [0, 8]);
return {
transform: [{ scale }],
shadowOpacity,
shadowOffset: { width: 0, height: 0 },
shadowRadius: 32,
elevation,
};
}, []);
const menuAnimatedStyle = useAnimatedStyle(() => {
const translateY = interpolate(
menuProgress.value,
[0, 1],
[hideMenuOffset, 0]
);
return {
transform: [{ translateY }],
};
}, [hideMenuOffset]);
const appPointerEvents = useAnimatedStyle(() => {
return {
pointerEvents: menuProgress.value === 1 ? "box-only" : "auto",
};
}, [menuProgress]);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => {
if (menuProgress.value === 1) {
menuProgress.value = withTiming(0, {
duration: 300,
easing: Easing.ease,
});
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
return false;
}
if (gestureState.numberActiveTouches === 2) {
longPressTimer.current = setTimeout(() => {
menuProgress.value = withTiming(1, {
duration: 300,
easing: Easing.ease,
});
longPressTimer.current = null;
}, 500);
return true;
}
return false;
},
onPanResponderEnd: (_evt, _gestureState) => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
},
}),
[menuProgress.value]
);
const menuHeaderStyle = useMemo(
() => ({
...menuStyles.menuHeader,
marginTop: insets.top + 10,
}),
[insets.top]
);
if (state.isLoading) {
return null
}
return (
<View style={styles.container}>
<Animated.View
style={[styles.fill, animatedStyle]}
pointerEvents="box-none"
{...panResponder.panHandlers}
>
<Animated.View style={[styles.fillContent, appPointerEvents]}>
{!state.showWebView ? (
children
) : (
<WebView
source={{ uri: getWebAppUrl() }}
style={[styles.webView, { paddingTop: insets.top }]}
/>
)}
</Animated.View>
</Animated.View>
<Animated.View style={[styles.menuContainer, menuAnimatedStyle]}>
<View style={menuStyles.menuContainerStyle}>
<View style={menuHeaderStyle}>
<View style={menuStyles.leftSection}>
<TouchableOpacity
onPress={toggleWebView}
style={menuStyles.button}
>
<MobileViewIcon
color={state.showWebView ? "#7E7F80" : "#18191B"}
/>
{!state.showWebView && <ActiveDot />}
</TouchableOpacity>
<TouchableOpacity
onPress={toggleWebView}
style={menuStyles.button}
>
<WebViewIcon color={state.showWebView ? "#18191B" : "#7E7F80"} />
{state.showWebView && <ActiveDot />}
</TouchableOpacity>
</View>
<View style={menuStyles.buttonContainer}>
<TouchableOpacity
onPress={reloadApp}
style={menuStyles.button}
>
<RefreshIcon />
</TouchableOpacity>
<TouchableOpacity onPress={exitApp} style={menuStyles.button}>
<CloseIcon />
</TouchableOpacity>
</View>
</View>
</View>
</Animated.View>
<InstructionsOverlay
showTint={state.showTint}
width={width}
height={height}
/>
</View>
);
}
: ({ children }: { children: React.ReactNode }) => children;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
fillContent: {
flex: 1,
borderRadius: 16,
overflow: "hidden",
},
fill: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
menuContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
},
menuTouchable: {
flex: 1,
},
bottomSheetBackground: {
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
},
contentContainer: {
flex: 1,
paddingHorizontal: 20,
paddingVertical: 20,
},
webViewContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#fff",
zIndex: 2000,
},
webViewHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 18,
backgroundColor: "#fff",
borderBottomWidth: 1,
borderBottomColor: "#e0e0e0",
},
webViewTitle: {
fontSize: 18,
fontWeight: "600",
color: "#18191B",
},
webViewCloseButton: {
width: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
webView: {
flex: 1,
},
});
const menuStyles = StyleSheet.create({
menuContainerStyle: {
backgroundColor: "#fff",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
menuHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 18,
},
appIcon: {
width: 44,
height: 44,
borderRadius: 12,
marginRight: 20,
},
appTitle: {
fontSize: 18,
fontWeight: "600",
color: "#18191B",
flex: 1,
},
buttonContainer: {
flexDirection: "row",
alignItems: "center",
gap: 28,
},
button: {
width: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
leftSection: {
flexDirection: "row",
alignItems: "center",
gap: 28,
flex: 1,
},
holdTwoFingersTextContainer: {
zIndex: 1,
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
transform: [{ translateY: -24 }],
},
holdTwoFingersText: {
fontSize: 28,
color: "#fff",
fontWeight: "600",
},
});
export default function Screen({ children }: { children: React.ReactNode }) {
return (
<SafeAreaProvider>
<AnythingMenu>{children}</AnythingMenu>
</SafeAreaProvider>
);
}

View File

@@ -1,106 +0,0 @@
import * as SecureStore from 'expo-secure-store';
import { fetch as expoFetch } from 'expo/fetch';
const originalFetch = fetch;
const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
const getURLFromArgs = (...args: Parameters<typeof fetch>) => {
const [urlArg] = args;
if (typeof urlArg === 'string') {
return urlArg;
}
if (urlArg instanceof Request) {
return urlArg.url;
}
// URL type may not be in the fetch signature for all TS environments
if (typeof urlArg === 'object' && urlArg !== null && 'href' in urlArg) {
return (urlArg as URL).href;
}
return null;
};
const isFileURL = (url: string) => {
return url.startsWith('file://') || url.startsWith('data:');
};
const isStaticAssetURL = (url: string) => {
return /\.(wasm|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)(\?|$)/i.test(url);
};
const isFirstPartyURL = (url: string) => {
return (
url.startsWith('/') ||
(process.env.EXPO_PUBLIC_BASE_URL && url.startsWith(process.env.EXPO_PUBLIC_BASE_URL))
);
};
const isSecondPartyURL = (url: string) => {
return url.startsWith('/_create/');
};
type Params = Parameters<typeof expoFetch>;
const fetchToWeb = async function fetchWithHeaders(...args: Params) {
const firstPartyURL = process.env.EXPO_PUBLIC_BASE_URL;
const secondPartyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
if (!firstPartyURL || !secondPartyURL) {
return expoFetch(...args);
}
const [input, init] = args;
const url = getURLFromArgs(input, init);
if (!url) {
return expoFetch(input, init);
}
if (isFileURL(url) || isStaticAssetURL(url)) {
return originalFetch(input, init);
}
const isExternalFetch = !isFirstPartyURL(url);
// we should not add headers to requests that don't go to our own server
if (isExternalFetch) {
return expoFetch(input, init);
}
let finalInput = input;
const baseURL = isSecondPartyURL(url) ? secondPartyURL : firstPartyURL;
if (typeof input === 'string') {
finalInput = input.startsWith('/') ? `${baseURL}${input}` : input;
} else {
return expoFetch(input, init);
}
const initHeaders = init?.headers ?? {};
const finalHeaders = new Headers(initHeaders);
const headers = {
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
host: process.env.EXPO_PUBLIC_HOST,
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST,
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST,
};
for (const [key, value] of Object.entries(headers)) {
if (value) {
finalHeaders.set(key, value);
}
}
const auth = await SecureStore.getItemAsync(authKey)
.then((auth) => {
return auth ? JSON.parse(auth) : null;
})
.catch(() => {
return null;
});
if (auth) {
finalHeaders.set('authorization', `Bearer ${auth.jwt}`);
}
return expoFetch(finalInput, {
...init,
headers: finalHeaders,
});
};
export default fetchToWeb;

View File

@@ -1,20 +0,0 @@
<svg width={128} height={128} viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="895" height="895" rx="19" fill="#E9E7E7" />
<g stroke="#C0C0C0" stroke-width="1.00975">
<line x1="447.505" y1="-23" x2="447.505" y2="901" />
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505" />
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068" />
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504" />
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061" />
<line x1="282.495" y1="907" x2="282.495" y2="-30" />
<line x1="611.495" y1="907" x2="611.495" y2="-30" />
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061" />
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53" />
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612" />
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643" />
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772" />
<circle cx="447.5" cy="441.5" r="163.995" />
<circle cx="447.911" cy="447.911" r="237.407" />
<circle cx="448" cy="442" r="384.495" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
import updatedFetch from './fetch';
// @ts-expect-error -- updatedFetch wraps the native fetch with custom headers
global.fetch = updatedFetch;

View File

@@ -1,46 +0,0 @@
import { Tabs } from 'expo-router';
import { Timer, History, Settings } from 'lucide-react-native';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
paddingTop: 4,
},
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#6B7280',
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '500',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Stopwatch',
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'Geschiedenis',
tabBarIcon: ({ color }) => <History color={color} size={24} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Instellingen',
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
}}
/>
</Tabs>
);
}

View File

@@ -1,233 +0,0 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
import { useQuery } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
export default function HistoryScreen() {
const insets = useSafeAreaInsets();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const { data: logs = [], isLoading } = useQuery({
queryKey: ['logs'],
queryFn: async () => {
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
if (!res.ok) throw new Error('Failed to fetch logs');
return res.json();
},
});
const handleExport = async () => {
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
const supported = await Linking.canOpenURL(exportUrl);
if (supported) {
await Linking.openURL(exportUrl);
} else {
Alert.alert('Fout', 'Kan de export-URL niet openen');
}
};
const formatDuration = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) return `${hrs}h ${mins}m`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
if (!fontsLoaded && !fontError) return null;
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Geschiedenis
</Text>
<TouchableOpacity
onPress={handleExport}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
gap: 6,
}}
>
<Download color="#2563EB" size={16} />
<Text
style={{
color: '#2563EB',
fontWeight: '500',
fontSize: 13,
fontFamily: 'Inter_600SemiBold',
}}
>
Exporteer CSV
</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
{logs.length === 0 && !isLoading ? (
<View style={{ alignItems: 'center', marginTop: 100 }}>
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
Nog geen opgeslagen sessies.
</Text>
</View>
) : (
logs.map((log: any) => (
<View
key={log.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
padding: 16,
marginBottom: 12,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<View>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
fontFamily: 'Inter_600SemiBold',
}}
>
{log.task_name}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Calendar color="#6B7280" size={12} />
<Text
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
>
{formatDate(log.start_time)} {formatTime(log.start_time)}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
{log.insole_type && (
<View
style={{
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#374151',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.insole_type}
</Text>
</View>
)}
{log.pair_count != null && (
<View
style={{
backgroundColor: '#EFF6FF',
borderWidth: 1,
borderColor: '#BFDBFE',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Layers color="#2563EB" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#2563EB',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
</Text>
</View>
)}
<View
style={{
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Clock color="#111827" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
{formatDuration(log.duration_seconds)}
</Text>
</View>
</View>
</View>
</View>
))
)}
</ScrollView>
</View>
);
}

View File

@@ -1,658 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
TextInput,
Modal,
Animated,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof INSOLE_TYPES)[number];
export default function TimerScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
// fontError: if fonts fail to load on Android we still render (no freeze)
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [showPicker, setShowPicker] = useState(false);
const [discardPending, setDiscardPending] = useState(false);
const [insoleCount, setInsoleCount] = useState(2);
const [insoleCountText, setInsoleCountText] = useState('2');
const timerRef = useRef<NodeJS.Timeout | null>(null);
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const saveLogMutation = useMutation({
mutationFn: async (log: any) => {
const res = await fetch(`${BASE_URL}/api/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log),
});
if (!res.ok) throw new Error('Failed to save log');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
useEffect(() => {
if (isRunning && !isPaused) {
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isRunning, isPaused]);
const openPicker = () => {
setShowPicker(true);
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
};
const closePicker = () => {
Animated.timing(slideAnim, {
toValue: SHEET_HEIGHT,
duration: 250,
useNativeDriver: true,
}).start(() => setShowPicker(false));
};
const handleStart = () => {
if (!activeTaskId) return;
setIsRunning(true);
setIsPaused(false);
setStartTime(new Date());
};
const handlePause = () => setIsPaused(true);
const handleResume = () => setIsPaused(false);
const handleStop = () => {
if (!activeTaskId || !startTime) return;
setIsRunning(false);
setIsPaused(false);
const endTime = new Date();
saveLogMutation.mutate({
task_id: activeTaskId,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
duration_seconds: elapsedTime,
pair_count: insoleCount,
insole_type: insoleType,
});
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
};
const handleDiscard = () => {
if (!discardPending) {
setDiscardPending(true);
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
} else {
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
setIsRunning(false);
setIsPaused(false);
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
}
};
const handleInsoleCountChange = (text: string) => {
setInsoleCountText(text);
const parsed = parseInt(text, 10);
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
};
const adjustInsoleCount = (delta: number) => {
const next = Math.max(1, insoleCount + delta);
setInsoleCount(next);
setInsoleCountText(String(next));
};
const formatTime = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
if (!fontsLoaded && !fontError) return null;
const regular = fontError ? undefined : 'Inter_400Regular';
const semibold = fontError ? undefined : 'Inter_600SemiBold';
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
const canStart = !!activeTaskId;
const filteredTasks = tasks.filter((t: any) =>
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
);
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<ScrollView contentContainerStyle={{ padding: 24 }}>
{/* 1. Type zool */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type zool
</Text>
<View style={{ flexDirection: 'row' }}>
{INSOLE_TYPES.map((type, i) => {
const selected = insoleType === type;
return (
<TouchableOpacity
key={type}
onPress={() => {
if (isRunning) return;
setInsoleType(type);
setActiveTaskId(null);
}}
disabled={isRunning}
style={{
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 2,
borderColor: selected ? '#2563EB' : '#E5E7EB',
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
fontFamily: semibold,
}}
>
{type}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* 2. Type handeling */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type handeling
</Text>
<TouchableOpacity
onPress={() => !isRunning && openPicker()}
disabled={isRunning}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
}}
>
<Text
style={{
fontSize: 16,
color: activeTaskId ? '#111827' : '#9CA3AF',
fontFamily: regular,
}}
>
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
</Text>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
</View>
{/* 3. Aantal zolen */}
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Aantal zolen
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'stretch',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
overflow: 'hidden',
}}
>
<TouchableOpacity
onPress={() => adjustInsoleCount(-1)}
disabled={isRunning || insoleCount <= 1}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
</Text>
</TouchableOpacity>
<TextInput
value={insoleCountText}
onChangeText={handleInsoleCountChange}
keyboardType="number-pad"
editable={!isRunning}
style={{
flex: 1,
textAlign: 'center',
fontSize: 22,
fontWeight: '600',
color: isRunning ? '#9CA3AF' : '#111827',
fontFamily: semibold,
paddingVertical: Platform.OS === 'android' ? 10 : 14,
paddingHorizontal: 0,
}}
/>
<TouchableOpacity
onPress={() => adjustInsoleCount(1)}
disabled={isRunning}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
+
</Text>
</TouchableOpacity>
</View>
</View>
{/* 4. Stopwatch display */}
<TouchableOpacity
onPress={() => {
if (!isRunning && canStart) handleStart();
else if (isRunning) {
if (isPaused) handleResume();
else handlePause();
}
}}
activeOpacity={canStart || isRunning ? 0.75 : 1}
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
backgroundColor: '#F9FAFB',
borderRadius: 24,
borderWidth: 1,
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
}}
>
<Text
style={{
fontSize: 64,
fontWeight: '600',
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
fontFamily: semibold,
letterSpacing: -2,
}}
>
{formatTime(elapsedTime)}
</Text>
{isRunning ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{
fontSize: 12,
color: isPaused ? '#D97706' : '#2563EB',
fontWeight: '500',
fontFamily: semibold,
}}
>
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
</Text>
</View>
) : canStart ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
>
Tik om te starten
</Text>
</View>
) : null}
</TouchableOpacity>
{/* 5. Knoppen */}
<View style={{ marginTop: 40 }}>
{!isRunning ? (
<TouchableOpacity
onPress={handleStart}
disabled={!canStart}
style={{
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Play
fill={canStart ? 'white' : '#9CA3AF'}
color={canStart ? 'white' : '#9CA3AF'}
size={24}
style={{ marginRight: 8 }}
/>
<Text
style={{
color: canStart ? 'white' : '#9CA3AF',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
Start Stopwatch
</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
onPress={handleStop}
style={{
backgroundColor: '#DC2626',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
marginBottom: 12,
}}
>
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
<Text
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
>
Stop & Opslaan
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDiscard}
style={{
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Text
style={{
color: discardPending ? '#ffffff' : '#6B7280',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
</Text>
</TouchableOpacity>
</>
)}
</View>
</ScrollView>
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
<Modal
visible={showPicker}
transparent
animationType="none"
onRequestClose={closePicker}
statusBarTranslucent
>
<View style={{ flex: 1 }}>
{/* Backdrop */}
<Pressable
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
onPress={closePicker}
/>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: SHEET_HEIGHT,
backgroundColor: '#ffffff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
transform: [{ translateY: slideAnim }],
}}
>
{/* Drag handle */}
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
</View>
{/* Sheet header */}
<View
style={{
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
>
Type handeling
</Text>
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
Kies een handeling
</Text>
</View>
{/* Task list */}
<ScrollView
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
showsVerticalScrollIndicator={false}
>
{filteredTasks.length === 0 ? (
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
<Text
style={{
fontSize: 15,
color: '#9CA3AF',
textAlign: 'center',
fontFamily: regular,
}}
>
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
Instellingen.
</Text>
</View>
) : (
filteredTasks.map((task: any) => {
const selected = activeTaskId === task.id;
return (
<TouchableOpacity
key={task.id}
onPress={() => {
setActiveTaskId(task.id);
closePicker();
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 18,
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
color: selected ? '#2563EB' : '#374151',
fontFamily: selected ? semibold : regular,
}}
>
{task.name}
</Text>
{selected && <Check size={20} color="#2563EB" />}
</TouchableOpacity>
);
})
)}
</ScrollView>
</Animated.View>
</View>
</Modal>
</View>
);
}

View File

@@ -1,574 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof ALL_TYPES)[number];
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
};
function TypeToggle({
type,
selected,
onPress,
}: {
type: InsoleType;
selected: boolean;
onPress: () => void;
}) {
const c = TYPE_COLORS[type];
return (
<TouchableOpacity
onPress={onPress}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 2,
borderColor: selected ? c.border : '#E5E7EB',
backgroundColor: selected ? c.bg : '#F9FAFB',
}}
>
{selected && <Check size={13} color={c.text} />}
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: selected ? c.text : '#9CA3AF',
fontFamily: 'Inter_600SemiBold',
}}
>
{type}
</Text>
</TouchableOpacity>
);
}
function TypeBadge({ type }: { type: InsoleType }) {
const c = TYPE_COLORS[type];
return (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
}}
>
<Text
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
>
{type}
</Text>
</View>
);
}
export default function TasksScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [newTaskName, setNewTaskName] = useState('');
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const addTaskMutation = useMutation({
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
const res = await fetch(`${BASE_URL}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to add task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setNewTaskName('');
setNewTaskTypes(['Kurk', 'Berk', '3D']);
},
});
const updateTaskMutation = useMutation({
mutationFn: async ({
id,
name,
insole_types,
}: {
id: number;
name: string;
insole_types: string[];
}) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to update task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setEditingId(null);
},
});
const deleteTaskMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
const toggleNewType = (type: InsoleType) => {
setNewTaskTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const toggleEditType = (type: InsoleType) => {
setEditingTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const handleAddTask = () => {
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
};
const handleStartEdit = (task: any) => {
setEditingId(task.id);
setEditingName(task.name);
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
};
const handleConfirmEdit = () => {
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
updateTaskMutation.mutate({
id: editingId,
name: editingName.trim(),
insole_types: editingTypes,
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
setEditingTypes([]);
};
const handleDelete = (task: any) => {
Alert.alert(
'Taak verwijderen',
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Verwijderen',
style: 'destructive',
onPress: () => deleteTaskMutation.mutate(task.id),
},
]
);
};
if (!fontsLoaded && !fontError) return null;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#ffffff' }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={{ paddingTop: insets.top, flex: 1 }}>
{/* Header */}
<View
style={{
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Instellingen
</Text>
<Text
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
>
Beheer handelingen per zooltype
</Text>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
{/* Add New Task */}
<View
style={{
backgroundColor: '#F9FAFB',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: '#E5E7EB',
marginBottom: 28,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Nieuwe handeling toevoegen
</Text>
{/* Name input */}
<TextInput
value={newTaskName}
onChangeText={setNewTaskName}
placeholder="Naam van de stap, bijv. Leerrand"
placeholderTextColor="#9CA3AF"
returnKeyType="done"
onSubmitEditing={handleAddTask}
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 11,
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
marginBottom: 12,
}}
/>
{/* Insole type toggles */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={newTaskTypes.includes(type)}
onPress={() => toggleNewType(type)}
/>
))}
</View>
<TouchableOpacity
onPress={handleAddTask}
disabled={
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
}
style={{
backgroundColor:
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
}}
>
{addTaskMutation.isPending ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Plus
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
size={18}
/>
<Text
style={{
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
fontSize: 15,
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
}}
>
Stap toevoegen
</Text>
</>
)}
</TouchableOpacity>
</View>
{/* Task List */}
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Huidige stappen ({tasks.length})
</Text>
{isLoading ? (
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
) : tasks.length === 0 ? (
<View style={{ alignItems: 'center', marginTop: 40 }}>
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
Nog geen stappen. Voeg er een toe hierboven.
</Text>
</View>
) : (
tasks.map((task: any) => {
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
const isEditing = editingId === task.id;
return (
<View
key={task.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
padding: 14,
marginBottom: 10,
}}
>
{isEditing ? (
<>
{/* Edit name */}
<TextInput
value={editingName}
onChangeText={setEditingName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleConfirmEdit}
style={{
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
paddingBottom: 8,
marginBottom: 12,
}}
/>
{/* Edit insole types */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={editingTypes.includes(type)}
onPress={() => toggleEditType(type)}
/>
))}
</View>
{/* Confirm / Cancel */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={handleConfirmEdit}
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
style={{
flex: 1,
backgroundColor: '#DCFCE7',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
{updateTaskMutation.isPending ? (
<ActivityIndicator color="#16A34A" size="small" />
) : (
<>
<Check size={16} color="#16A34A" />
<Text
style={{
color: '#16A34A',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Opslaan
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleCancelEdit}
style={{
flex: 1,
backgroundColor: '#F3F4F6',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
<X size={16} color="#6B7280" />
<Text
style={{
color: '#6B7280',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Annuleren
</Text>
</TouchableOpacity>
</View>
</>
) : (
<>
{/* Task name + actions */}
<View
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
>
<Text
style={{
flex: 1,
fontSize: 15,
color: '#374151',
fontFamily: 'Inter_400Regular',
}}
>
{task.name}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => handleStartEdit(task)}
style={{
backgroundColor: '#EFF6FF',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Pencil color="#2563EB" size={16} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDelete(task)}
disabled={deleteTaskMutation.isPending}
style={{
backgroundColor: '#FEF2F2',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Trash2 color="#DC2626" size={16} />
</TouchableOpacity>
</View>
</View>
{/* Insole type badges */}
<View style={{ flexDirection: 'row', gap: 6 }}>
{types.map((type) => (
<TypeBadge key={type} type={type} />
))}
</View>
</>
)}
</View>
);
})
)}
</ScrollView>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -1,436 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import {
type RelativePathString,
type SitemapType,
Stack,
useGlobalSearchParams,
useRouter,
useSitemap,
} from 'expo-router';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
interface ParentSitemap {
expoPages?: Array<{
id: string;
name: string;
filePath: string;
cleanRoute?: string;
}>;
}
function NotFoundScreen() {
const router = useRouter();
const params = useGlobalSearchParams();
const expoSitemap = useSitemap();
const [sitemap, setSitemap] = useState<SitemapType | ParentSitemap | null>(expoSitemap);
useEffect(() => {
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
const handler = (event: MessageEvent) => {
if (event.data.type === 'sandbox:sitemap') {
window.removeEventListener('message', handler);
setSitemap(event.data.sitemap);
}
};
window.parent.postMessage(
{
type: 'sandbox:sitemap',
},
'*'
);
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}
}, []);
const isExpoSitemap = sitemap === expoSitemap;
const missingPath = params['not-found']?.[0] || '';
const availableRoutes = useMemo(() => {
return (
expoSitemap?.children?.filter(
(child) =>
child.href &&
child.contextKey !== './auth.jsx' &&
child.contextKey !== './auth.web.jsx' &&
child.contextKey !== './+not-found.tsx' &&
child.contextKey !== 'expo-router/build/views/Sitemap.js'
) || []
);
}, [expoSitemap]);
const handleBack = () => {
if (router.canGoBack()) {
router.back();
} else {
const hasTabsIndex = expoSitemap?.children?.some(
(child) =>
child.contextKey === './(tabs)/_layout.jsx' &&
child.children.some((child) => child.contextKey === './(tabs)/index.jsx')
);
if (isExpoSitemap) {
if (hasTabsIndex) {
router.replace('../(tabs)/index.jsx');
} else {
router.replace('../');
}
} else {
router.replace('..');
}
}
};
const handleNavigate = (url: string) => {
try {
if (url) {
router.push(url as RelativePathString);
}
} catch (error) {
console.error('Navigation error:', error);
}
};
const handleCreatePage = useCallback(() => {
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
window.parent.postMessage(
{
type: 'sandbox:web:create',
path: missingPath,
view: 'mobile',
},
'*'
);
}
}, [missingPath]);
return (
<>
<Stack.Screen options={{ title: 'Page Not Found', headerShown: false }} />
<SafeAreaView style={styles.safeArea}>
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={18} color="#666" />
</TouchableOpacity>
<View style={styles.pathContainer}>
<View style={styles.pathPrefix}>
<Text style={styles.pathPrefixText}>/</Text>
</View>
<View style={styles.pathContent}>
<Text style={styles.pathText} numberOfLines={1}>
{missingPath}
</Text>
</View>
</View>
</View>
<View style={styles.mainContent}>
<Text style={styles.title}>Uh-oh! This screen doesn't exist (yet).</Text>
<Text style={styles.subtitle}>
Looks like "<Text style={styles.boldText}>/{missingPath}</Text>" isn't part of your
project. But no worries, you've got options!
</Text>
{typeof window !== 'undefined' && window.parent && window.parent !== window && (
<View style={styles.createPageContainer}>
<View style={styles.createPageContent}>
<View style={styles.createPageTextContainer}>
<Text style={styles.createPageTitle}>Build it from scratch</Text>
<Text style={styles.createPageDescription}>
Create a new screen to live at "/{missingPath}"
</Text>
</View>
<View style={styles.createPageButtonContainer}>
<TouchableOpacity
onPress={() => handleCreatePage()}
style={styles.createPageButton}
>
<Text style={styles.createPageButtonText}>Create Screen</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
<Text style={styles.routesLabel}>Check out all your project's routes here </Text>
{!isExpoSitemap && sitemap ? (
<View style={styles.pagesContainer}>
<View style={styles.pagesListContainer}>
<Text style={styles.pagesLabel}>MOBILE</Text>
{((sitemap as ParentSitemap).expoPages || []).map((route, _index: number) => (
<TouchableOpacity
key={route.id}
onPress={() => handleNavigate(route.cleanRoute || '')}
style={styles.pageButton}
>
<Text style={styles.routeName}>{route.name}</Text>
</TouchableOpacity>
))}
</View>
</View>
) : (
<View style={styles.pagesContainer}>
<View style={styles.pagesListContainer}>
<Text style={styles.pagesLabel}>MOBILE</Text>
{(availableRoutes as SitemapType[]).map((route: SitemapType, _index: number) => {
const url =
typeof route.href === 'string' ? route.href : route.href?.pathname || '/';
if (url === '/(tabs)' && route.children) {
return route.children.map((childRoute: SitemapType) => {
const childUrl =
typeof childRoute.href === 'string'
? childRoute.href
: childRoute.href.pathname || '/';
const displayPath =
childUrl === '/(tabs)'
? 'Homepage'
: childUrl.replace(/^\//, '').replace(/^\(tabs\)\//, '');
return (
<TouchableOpacity
key={childRoute.contextKey}
onPress={() => handleNavigate(childUrl)}
style={styles.pageButton}
>
<Text style={styles.routeName}>{displayPath}</Text>
</TouchableOpacity>
);
});
}
const displayPath = url === '/' ? 'Homepage' : url.replace(/^\//, '');
return (
<TouchableOpacity
key={route.contextKey}
onPress={() => handleNavigate(url)}
style={styles.pageButton}
>
<Text style={styles.routeName}>{displayPath}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
</>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#fff',
},
container: {
flex: 1,
backgroundColor: '#fff',
},
contentContainer: {
flexGrow: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
gap: 8,
},
backButton: {
width: 40,
height: 40,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
pathContainer: {
flexDirection: 'row',
height: 32,
width: 300,
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 8,
backgroundColor: '#f9f9f9',
overflow: 'hidden',
},
pathPrefix: {
paddingHorizontal: 14,
paddingVertical: 5,
justifyContent: 'center',
borderRightWidth: 1,
borderRightColor: '#e5e5e5',
},
pathPrefixText: {
color: '#666',
},
pathContent: {
flex: 1,
paddingHorizontal: 12,
justifyContent: 'center',
},
pathText: {
color: '#666',
},
mainContent: {
flex: 1,
alignItems: 'center',
paddingTop: 40,
paddingHorizontal: 20,
},
title: {
fontSize: 32,
fontWeight: '500',
color: '#111',
marginBottom: 16,
textAlign: 'center',
},
subtitle: {
paddingTop: 16,
paddingBottom: 48,
color: '#666',
textAlign: 'center',
fontSize: 16,
lineHeight: 24,
},
boldText: {
fontWeight: 'bold',
},
routesLabel: {
color: '#666',
marginBottom: 80,
textAlign: 'center',
},
createPageContainer: {
width: '100%',
maxWidth: 800,
marginBottom: 40,
paddingHorizontal: 20,
},
createPageContent: {
flexDirection: 'column',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 8,
padding: 20,
backgroundColor: '#fff',
gap: 15,
},
createPageTextContainer: {
gap: 10,
},
createPageTitle: {
fontSize: 14,
color: '#000',
fontWeight: '500',
textAlign: 'center',
},
createPageDescription: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
createPageButtonContainer: {
alignItems: 'flex-start',
justifyContent: 'center',
},
createPageButton: {
backgroundColor: '#000',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
createPageButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
pagesContainer: {
width: '100%',
alignItems: 'center',
paddingHorizontal: 20,
},
pagesLabel: {
fontSize: 14,
color: '#ccc',
alignSelf: 'flex-start',
marginBottom: 10,
paddingHorizontal: 16,
},
pagesListContainer: {
width: '100%',
maxWidth: 600,
gap: 10,
},
pageButton: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 8,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e5e5e5',
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.05)',
elevation: 1,
},
routeName: {
fontSize: 16,
fontWeight: '500',
color: '#111',
},
routePath: {
fontSize: 14,
color: '#999',
},
routesContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
width: '100%',
paddingHorizontal: 20,
paddingBottom: 20,
gap: 40,
},
routeCard: {
width: '100%',
maxWidth: 300,
minWidth: 150,
alignItems: 'center',
marginBottom: 12,
},
routeButton: {
width: '100%',
aspectRatio: 1.4,
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e5e5',
overflow: 'hidden',
},
routePreview: {
flex: 1,
backgroundColor: '#f9f9f9',
},
routeLabel: {
paddingTop: 12,
color: '#666',
textAlign: 'left',
width: '100%',
},
});
export default () => {
return (
<NotFoundScreen />
);
};

View File

@@ -1,71 +0,0 @@
/**
* This file is customizable BUT — do not remove:
* • `<AuthModal />` render (shipped v2 auth modal; removing it breaks
* signin/signup since useAuth().signIn() only flips state, not render)
* • `useAuth().initiate()` + `isReady` gate (loads persisted session from
* SecureStore — removing causes user to appear signed-out on app launch)
*
* Safe to change: the Stack routes, QueryClient config, splash behavior, the
* wrapping providers, or to add nested providers around <Stack>.
*/
'use client';
import { ErrorBoundary } from '@/__create/ErrorBoundary';
import { useAuth } from '@/utils/auth/useAuth';
import { AuthModal } from '@/utils/auth/useAuthModal';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
void SplashScreen.preventAutoHideAsync();
const SPLASH_TIMEOUT_MS = 10_000;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export default function RootLayout() {
const { initiate, isReady } = useAuth();
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
initiate();
}, [initiate]);
useEffect(() => {
const timeout = setTimeout(() => setTimedOut(true), SPLASH_TIMEOUT_MS);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (isReady || timedOut) {
void SplashScreen.hideAsync();
}
}, [isReady, timedOut]);
if (!isReady && !timedOut) {
return null;
}
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} initialRouteName="(tabs)">
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<AuthModal />
</GestureHandlerRootView>
</QueryClientProvider>
</ErrorBoundary>
);
}

View File

@@ -1,3 +0,0 @@
export default function Index() {
return null;
}

View File

@@ -1,147 +0,0 @@
import React, { useRef, useEffect, forwardRef } from 'react';
import { Platform, Keyboard, KeyboardAvoidingView, LayoutChangeEvent, ViewStyle, KeyboardEvent } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
interface Layout {
x: number;
y: number;
width: number;
height: number;
}
interface KeyboardAvoidingAnimatedViewProps {
children: React.ReactNode;
behavior?: 'padding' | 'height' | 'position';
keyboardVerticalOffset?: number;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
enabled?: boolean;
onLayout?: (event: LayoutChangeEvent) => void;
}
const KeyboardAvoidingAnimatedView = forwardRef<Animated.View, KeyboardAvoidingAnimatedViewProps>((props, ref) => {
const {
children,
behavior = Platform.OS === 'ios' ? 'padding' : 'height',
keyboardVerticalOffset = 0,
style,
contentContainerStyle,
enabled = true,
onLayout,
...leftoverProps
} = props;
const animatedViewRef = useRef<Layout | null>(null); // ref to animated view in this polyfill
const initialHeight = useSharedValue(0); // original height of animated view before keyboard appears
const bottomHeight = useSharedValue(0); // whats going to be added to the bottom when keyboard appears
useEffect(() => {
if (!enabled) return;
const onKeyboardShow = (event: KeyboardEvent) => {
const { duration, endCoordinates } = event;
const animatedView = animatedViewRef.current;
if (!animatedView) return;
let height = 0;
// calculate how much the view needs to move up
const keyboardY = endCoordinates.screenY - keyboardVerticalOffset;
height = Math.max(animatedView.y + animatedView.height - keyboardY, 0);
bottomHeight.value = withTiming(height, {
duration: duration > 10 ? duration : 300,
});
};
const onKeyboardHide = () => {
bottomHeight.value = withTiming(0, { duration: 300 });
};
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSubscription = Keyboard.addListener(showEvent, onKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, onKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [keyboardVerticalOffset, enabled, bottomHeight]);
const animatedStyle = useAnimatedStyle(() => {
if (behavior === 'height') {
return {
height: initialHeight.value - bottomHeight.value,
flex: bottomHeight.value > 0 ? 0 : (null as never),
};
}
if (behavior === 'padding') {
return {
paddingBottom: bottomHeight.value,
};
}
return {};
});
const positionAnimatedStyle = useAnimatedStyle(() => ({
bottom: bottomHeight.value,
}));
const handleLayout = (event: LayoutChangeEvent) => {
const layout = event.nativeEvent.layout;
animatedViewRef.current = layout;
// initial height before keybaord appears
if (initialHeight.value === 0) {
initialHeight.value = layout.height;
}
if (onLayout) {
onLayout(event);
}
};
const renderContent = () => {
if (behavior === 'position') {
return (
<Animated.View style={[contentContainerStyle, positionAnimatedStyle]}>
{children}
</Animated.View>
);
}
// render children if padding or height
return children;
};
// for web, default to unused keyboard avoiding view
if (Platform.OS === 'web') {
return (
<KeyboardAvoidingView
behavior={behavior}
style={style}
contentContainerStyle={contentContainerStyle}
{...leftoverProps}
>
{children}
</KeyboardAvoidingView>
);
}
return (
<Animated.View
ref={ref}
style={[style, animatedStyle]}
onLayout={handleLayout}
{...leftoverProps}
>
{renderContent()}
</Animated.View>
);
});
KeyboardAvoidingAnimatedView.displayName = 'KeyboardAvoidingAnimatedView';
export default KeyboardAvoidingAnimatedView;

View File

@@ -1,163 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth WebView. Handles both native (iOS/Android WebView +
* onShouldStartLoadWithRequest → fetch /api/auth/token → setAuth) and web
* (iframe + window.addEventListener('message') listening for AUTH_SUCCESS
* postMessage from /api/auth/expo-web-success). BOTH code paths are
* load-bearing; do NOT delete the web branch because you're only testing
* native, and vice versa. The postMessage contract { type, jwt, user } must
* stay in sync with /api/auth/expo-web-success/route.ts.
*/
'use client';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { WebView } from 'react-native-webview';
import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
import { useAuthStore } from './store';
const callbackUrl = '/api/auth/token';
const callbackQueryString = `callbackUrl=${callbackUrl}`;
// Normalize the expected origin once. `new URL(...).origin` strips trailing
// slashes, paths, and query — so a stray slash in EXPO_PUBLIC_PROXY_BASE_URL
// no longer silently drops every postMessage from the auth iframe.
const allowedOrigin = (() => {
const raw = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
if (!raw) return null;
try {
return new URL(raw).origin;
} catch {
return null;
}
})();
interface AuthWebViewProps {
mode: 'signup' | 'signin';
proxyURL: string;
baseURL: string;
}
interface AuthMessageData {
type: 'AUTH_SUCCESS' | 'AUTH_ERROR';
jwt?: string;
user?: {
id: string;
email: string;
name: string;
image: string;
};
error?: string;
}
/**
* This renders a WebView for authentication and handles both web and native platforms.
*/
export const AuthWebView = ({ mode, proxyURL, baseURL }: AuthWebViewProps) => {
const [currentURI, setURI] = useState(`${baseURL}/account/${mode}?${callbackQueryString}`);
const { auth, setAuth, isReady } = useAuthStore();
const isAuthenticated = isReady ? !!auth : null;
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (Platform.OS === 'web') {
return;
}
if (isAuthenticated) {
router.back();
}
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
return;
}
setURI(`${baseURL}/account/${mode}?${callbackQueryString}`);
}, [mode, baseURL, isAuthenticated]);
useEffect(() => {
if (typeof window === 'undefined' || !window.addEventListener) {
return;
}
const handleMessage = (event: MessageEvent<AuthMessageData>) => {
// Verify the origin for security. Compare normalized origins so a
// trailing slash or path in EXPO_PUBLIC_PROXY_BASE_URL doesn't drop
// legitimate messages. Surface drops via console.warn instead of
// silently swallowing them.
if (allowedOrigin && event.origin !== allowedOrigin) {
console.warn(
`AuthWebView: dropping message from unexpected origin ${event.origin}; expected ${allowedOrigin}`
);
return;
}
if (event.data.type === 'AUTH_SUCCESS') {
setAuth({
jwt: event.data.jwt!,
user: event.data.user!,
});
} else if (event.data.type === 'AUTH_ERROR') {
console.error('Auth error:', event.data.error);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [setAuth]);
if (Platform.OS === 'web') {
const handleIframeError = () => {
console.error('Failed to load auth iframe');
};
return (
<iframe
ref={iframeRef}
title="Authentication"
src={`${proxyURL}/account/${mode}?callbackUrl=/api/auth/expo-web-success`}
style={{ width: '100%', height: '100%', border: 'none' }}
onError={handleIframeError}
/>
);
}
return (
<WebView
sharedCookiesEnabled
source={{
uri: currentURI,
}}
headers={{
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID!,
host: process.env.EXPO_PUBLIC_HOST!,
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST!,
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST!,
}}
onShouldStartLoadWithRequest={(request: WebViewNavigation) => {
if (request.url === `${baseURL}${callbackUrl}`) {
fetch(request.url)
.then((response) => response.json())
.then((data) => {
setAuth({ jwt: data.jwt, user: data.user });
})
.catch(() => {});
return false;
}
if (request.url === currentURI) return true;
// Add query string properly by checking if URL already has parameters
const hasParams = request.url.includes('?');
const separator = hasParams ? '&' : '?';
const newURL = request.url.replaceAll(proxyURL, baseURL);
if (newURL.endsWith(callbackUrl)) {
setURI(newURL);
return false;
}
setURI(`${newURL}${separator}${callbackQueryString}`);
return false;
}}
style={{ flex: 1 }}
/>
);
};

View File

@@ -1,40 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth helpers. `authFetch` auto-adds Authorization: Bearer <jwt>
* when a session exists — use it instead of bare fetch() for calls to the
* web app's API routes. The web server's better-auth bearer() plugin
* validates these headers. DO NOT reimplement these helpers in user code.
*/
'use client';
import { useAuthStore } from './store';
/**
* Read the current session (jwt + user) synchronously from the auth store.
* Returns null if the user is not authenticated.
*/
export const getSession = () => useAuthStore.getState().auth;
/**
* Read the current session's JWT for use in an Authorization header.
* Returns null if the user is not authenticated.
*/
export const getJwt = () => useAuthStore.getState().auth?.jwt ?? null;
/**
* Drop-in replacement for fetch() that automatically adds the
* `Authorization: Bearer <jwt>` header when the user is signed in. Use this
* for calls from the mobile app to the web app's API routes — the web server
* uses better-auth's `bearer()` plugin to authenticate these requests.
*
* Existing Authorization headers on the caller's `init.headers` are preserved.
*/
export const authFetch: typeof fetch = (input, init) => {
const jwt = getJwt();
const headers = new Headers(init?.headers);
if (jwt && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${jwt}`);
}
return fetch(input, { ...init, headers });
};

View File

@@ -1,14 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth barrel. Keeps the public import surface stable so user
* code can `import { useAuth, useUser, useAuthModal, authFetch } from '@/utils/auth'`.
* DO NOT remove or rename these re-exports — downstream code breaks.
*/
import { useAuth, useRequireAuth } from './useAuth';
import { useUser } from './useUser';
import { useAuthModal } from './store';
export { useAuth, useRequireAuth, useUser, useAuthModal };
export { authFetch, getJwt, getSession } from './getSession';
export default useAuth;

View File

@@ -1,95 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 zustand stores for auth session (persisted to SecureStore) and
* auth modal open/close state. `useAuth`, `useAuthModal` component, and
* `AuthWebView` all read from these stores — renaming fields or changing
* shape breaks all three. DO NOT replace with Context, DO NOT merge into a
* single store.
*/
import * as SecureStore from 'expo-secure-store';
import { create } from 'zustand';
export const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
/**
* Explicit Keychain options used on every SecureStore call in the auth flow.
*
* - keychainService: pinned to a stable name so reads and writes always hit
* the same partition. Without this, SecureStore derives a service name from
* the bundle that can drift between Classic and EAS builds, causing reads
* to miss writes from a previous build.
* - keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY allows the auth
* token to be read on every cold launch after the device has been unlocked
* once since boot. The default (WHEN_UNLOCKED) refuses access during the
* first-unlock window, which is the most common TestFlight failure mode.
* - requireAuthentication: false keeps SecureStore on its non-biometric code
* path, so it never reads NSFaceIDUsageDescription or constructs a
* biometry-current-set access control object — both of which can throw
* NSException and trip iOS 26's unhandled async-void TurboModule rethrow.
*/
export const secureStoreOptions: SecureStore.SecureStoreOptions = {
keychainService: 'anything-auth',
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
requireAuthentication: false,
};
export interface User {
id: string;
email: string;
name: string;
image: string;
}
export interface Auth {
jwt: string;
user: User;
}
interface AuthState {
isReady: boolean;
auth: Auth | null;
setAuth: (auth: Auth | null) => void;
}
/**
* This store manages the authentication state of the application.
*/
export const useAuthStore = create<AuthState>((set) => ({
isReady: false,
auth: null,
setAuth: (auth) => {
if (auth) {
SecureStore.setItemAsync(
authKey,
JSON.stringify(auth),
secureStoreOptions,
).catch(() => {
// Swallow Keychain write errors — the app remains in-memory authed
// for this session and the next launch will re-auth via the WebView.
// Throwing here would propagate into the unhandled-rejection /
// TurboModule rethrow path and crash on iOS 26.x.
});
} else {
SecureStore.deleteItemAsync(authKey, secureStoreOptions).catch(() => {});
}
set({ auth });
},
}));
interface AuthModalState {
isOpen: boolean;
mode: 'signup' | 'signin';
open: (options?: { mode?: 'signup' | 'signin' }) => void;
close: () => void;
}
/**
* This store manages the state of the authentication modal.
*/
export const useAuthModal = create<AuthModalState>((set) => ({
isOpen: false,
mode: 'signup',
open: (options) => set({ isOpen: true, mode: options?.mode || 'signup' }),
close: () => set({ isOpen: false }),
}));

View File

@@ -1,101 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 mobile auth hook. `useAuth()` is the public surface for
* user apps — `{ signIn, signUp, signOut, auth, isAuthenticated, isReady }`.
* These names are documented in the v2 auth prompt; user code imports them
* directly. DO NOT rename them, DO NOT remove `initiate()` (it loads the
* persisted session from SecureStore), and DO NOT add side effects that run
* before isReady flips true.
*/
import * as SecureStore from 'expo-secure-store';
import { useCallback, useEffect } from 'react';
import { authKey, type Auth, secureStoreOptions, useAuthModal, useAuthStore } from './store';
interface UseAuthReturn {
isReady: boolean;
isAuthenticated: boolean | null;
signIn: () => void;
signOut: () => void;
signUp: () => void;
auth: Auth | null;
setAuth: (auth: Auth | null) => void;
initiate: () => void;
}
/**
* This hook provides authentication functionality.
* It may be easier to use the `useAuthModal` or `useRequireAuth` hooks
* instead as those will also handle showing authentication to the user
* directly.
*/
export const useAuth = (): UseAuthReturn => {
const { isReady, auth, setAuth } = useAuthStore();
const { isOpen: _isOpen, close, open } = useAuthModal();
const initiate = useCallback(() => {
// The auth state machine must always reach a terminal state. SecureStore
// can throw or hang in TestFlight release builds (Keychain access denied,
// missing keychain-access-groups entitlement after EAS migration, locked
// device first-unlock state, or iOS 26 TurboModule rethrow). Without a
// catch the unhandled rejection leaves isReady=false forever and the
// RootLayout renders null — the user sees a blank screen indefinitely.
Promise.race<string | null>([
SecureStore.getItemAsync(authKey, secureStoreOptions),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 3000)),
])
.then((authString) => {
useAuthStore.setState({
auth: authString ? (JSON.parse(authString) as Auth) : null,
isReady: true,
});
})
.catch(() => {
useAuthStore.setState({ auth: null, isReady: true });
});
}, []);
useEffect(() => {}, []);
const signIn = useCallback(() => {
open({ mode: 'signin' });
}, [open]);
const signUp = useCallback(() => {
open({ mode: 'signup' });
}, [open]);
const signOut = useCallback(() => {
setAuth(null);
close();
}, [close, setAuth]);
return {
isReady,
isAuthenticated: isReady ? !!auth : null,
signIn,
signOut,
signUp,
auth,
setAuth,
initiate,
};
};
interface UseRequireAuthOptions {
mode?: 'signup' | 'signin';
}
export const useRequireAuth = (options?: UseRequireAuthOptions): UseAuthReturn => {
const authReturn = useAuth();
const { open } = useAuthModal();
useEffect(() => {
if (!authReturn.isAuthenticated && authReturn.isReady) {
open({ mode: options?.mode });
}
}, [authReturn.isAuthenticated, open, options?.mode, authReturn.isReady]);
return authReturn;
};
export default useAuth;

View File

@@ -1,104 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 <AuthModal /> — the modal that wraps the AuthWebView. It's
* already mounted in app/_layout.tsx; DO NOT mount it again. The env-var
* preflight (returns a "not configured" modal when EXPO_PUBLIC_BASE_URL or
* EXPO_PUBLIC_PROXY_BASE_URL is missing) is intentional — removing it turns
* env-var misconfig into a silent "nothing happens" bug. The named export of
* useAuthModal at the top is also load-bearing (user code imports it from
* this file, not just from ./store).
*/
'use client';
import React from 'react';
import { Modal, Text, View } from 'react-native';
import { AuthWebView } from './AuthWebView';
import { useAuthModal, useAuthStore } from './store';
export { useAuthModal } from './store';
/**
* This component renders a modal for authentication purposes.
* To show it programmatically, you should either use the `useRequireAuth` hook or the `useAuthModal` hook.
*
* @example
* ```js
* import { useAuthModal } from '@/utils/useAuthModal';
* function MyComponent() {
* const { open } = useAuthModal();
* return <Button title="Login" onPress={() => open({ mode: 'signin' })} />;
* }
* ```
*
* @example
* ```js
* import { useRequireAuth } from '@/utils/auth';
* function MyComponent() {
* // automatically opens the auth modal if the user is not authenticated
* useRequireAuth();
* return <Text>Protected Content</Text>;
* }
*
*/
export const AuthModal = () => {
const { auth } = useAuthStore();
const { isOpen, mode } = useAuthModal();
const proxyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const baseURL = process.env.EXPO_PUBLIC_BASE_URL;
if (!proxyURL || !baseURL) {
const missing = [
!proxyURL && 'EXPO_PUBLIC_PROXY_BASE_URL',
!baseURL && 'EXPO_PUBLIC_BASE_URL',
]
.filter(Boolean)
.join(', ');
console.error(
`AuthModal: missing required env var(s): ${missing}. Auth cannot open.`
);
return (
<Modal
visible={isOpen && !auth}
animationType="slide"
presentationStyle="pageSheet"
>
<View className="flex-1 items-center justify-center bg-white p-[24px]">
<Text className="mb-[8px] text-[18px] font-semibold">
Auth is not configured
</Text>
<Text className="text-center text-[14px] text-gray-600">
Missing environment variable{missing.includes(',') ? 's' : ''}:{' '}
{missing}. Set {missing.includes(',') ? 'them' : 'it'} in your .env
and restart the app.
</Text>
</View>
</Modal>
);
}
return (
<Modal visible={isOpen && !auth} animationType="slide" presentationStyle='pageSheet'>
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '100%',
width: '100%',
backgroundColor: '#fff',
padding: 0,
}}
>
<AuthWebView
mode={mode}
proxyURL={proxyURL}
baseURL={baseURL}
/>
</View>
</Modal>
);
};
export default useAuthModal;

View File

@@ -1,24 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* V1-compatible mobile user hook. Migrated apps commonly import
* `@/utils/auth/useUser` and expect `{ user, data, loading, refetch }`.
* Keep this surface stable; the V2 auth state still comes from `useAuth()`.
*/
import { useCallback } from 'react';
import { useAuth } from './useAuth';
export const useUser = () => {
const { auth, isReady } = useAuth();
const user = auth?.user ?? null;
const refetch = useCallback(async () => user, [user]);
return {
user,
data: user,
loading: !isReady,
refetch,
};
};
export default useUser;

View File

@@ -1,17 +0,0 @@
export const mockConfigure = jest.fn();
export const mockSetLogLevel = jest.fn();
export const mockGetOfferings = jest.fn();
export const mockPurchasePackage = jest.fn();
export const mockRestorePurchases = jest.fn();
const Purchases = {
configure: (...args: any[]) => mockConfigure(...args),
setLogLevel: (...args: any[]) => mockSetLogLevel(...args),
getOfferings: (...args: any[]) => mockGetOfferings(...args),
purchasePackage: (...args: any[]) => mockPurchasePackage(...args),
restorePurchases: (...args: any[]) => mockRestorePurchases(...args),
};
export default Purchases;
export const LOG_LEVEL = { INFO: 'INFO' };
export const PRODUCT_CATEGORY = { SUBSCRIPTION: 'SUBSCRIPTION' };

View File

@@ -1,4 +0,0 @@
export const Platform = {
select: (opts: Record<string, any>) => opts.ios,
OS: 'ios',
};

View File

@@ -1,382 +0,0 @@
/**
* Tests for the useInAppPurchase logic functions.
*
* These verify:
* 1. Original behavior from the inlined documentation.ts code is preserved
* (SDK configuration, offerings loading, subscription status, purchasing)
* 2. Bug fixes over the old inline code:
* - Offerings are awaited before isReady is set (was fire-and-forget)
* - Retry logic handles TestFlight cold-start failures
* - getAvailablePackages returns [] instead of throwing on null offerings
* - Purchases.configure() is only called once
* - restorePurchases is included (App Store Guideline 3.1.1)
*/
import {
mockConfigure,
mockSetLogLevel,
mockGetOfferings,
mockPurchasePackage,
mockRestorePurchases,
} from './__mocks__/react-native-purchases';
import {
getRevenueCatAPIKey,
loadOfferings,
fetchSubscriptionStatus,
initiatePurchases,
getAvailablePackagesFromOfferings,
getSubscriptionsFromOfferings,
executePurchase,
executeRestore,
} from '../useInAppPurchase';
// --- Helpers ---
const makeOfferings = (hasCurrent = true) => ({
current: hasCurrent
? {
availablePackages: [
{
identifier: 'lifetime',
product: {
priceString: '$1.99',
productCategory: 'SUBSCRIPTION',
},
},
{
identifier: 'credits',
product: {
priceString: '$4.99',
productCategory: 'NON_SUBSCRIPTION',
},
},
],
}
: null,
});
function makeStoreCallbacks() {
return {
setOfferings: jest.fn(),
setIsSubscribed: jest.fn(),
setIsReady: jest.fn(),
isConfigured: { current: false },
};
}
// --- Setup ---
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
process.env.EXPO_PUBLIC_CREATE_ENV = 'PRODUCTION';
process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY = 'pk_ios_test';
process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY = 'pk_android_test';
process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY = 'pk_test_test';
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
afterEach(() => {
jest.useRealTimers();
});
// --- Tests ---
describe('getRevenueCatAPIKey', () => {
test('returns iOS key in production', () => {
expect(getRevenueCatAPIKey()).toBe('pk_ios_test');
});
test('returns test store key in DEVELOPMENT', () => {
process.env.EXPO_PUBLIC_CREATE_ENV = 'DEVELOPMENT';
expect(getRevenueCatAPIKey()).toBe('pk_test_test');
});
test('returns undefined when no keys are set', () => {
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
expect(getRevenueCatAPIKey()).toBeUndefined();
});
});
describe('initiatePurchases', () => {
test('configures SDK with correct API key', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockConfigure).toHaveBeenCalledWith({ apiKey: 'pk_ios_test' });
});
test('sets log level to INFO', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockSetLogLevel).toHaveBeenCalledWith('INFO');
});
test('loads offerings and fetches subscription status in parallel', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(cbs.setOfferings).toHaveBeenCalledWith(makeOfferings());
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
test('sets isReady true after completion', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
});
test('does not configure when no API key is available', async () => {
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockConfigure).not.toHaveBeenCalled();
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
});
test('BUG FIX: isReady only set AFTER offerings have loaded (was fire-and-forget)', async () => {
let resolveOfferings!: Function;
mockGetOfferings.mockImplementation(
() => new Promise((resolve) => { resolveOfferings = () => resolve(makeOfferings()); })
);
const cbs = makeStoreCallbacks();
const promise = initiatePurchases(cbs);
// Before offerings resolve, setIsReady should NOT have been called
expect(cbs.setIsReady).not.toHaveBeenCalled();
resolveOfferings();
await promise;
// Now it should be called
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
expect(cbs.setOfferings).toHaveBeenCalled();
});
test('BUG FIX: configure() only called once even if initiate() called multiple times', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
await initiatePurchases(cbs);
await initiatePurchases(cbs);
expect(mockConfigure).toHaveBeenCalledTimes(1);
});
});
describe('loadOfferings', () => {
test('stores offerings on success', async () => {
const offerings = makeOfferings();
mockGetOfferings.mockResolvedValue(offerings);
const setOfferings = jest.fn();
await loadOfferings(setOfferings);
expect(setOfferings).toHaveBeenCalledWith(offerings);
});
test('BUG FIX: retries up to 3 times on failure', async () => {
mockGetOfferings
.mockRejectedValueOnce(new Error('cold start'))
.mockRejectedValueOnce(new Error('still loading'))
.mockResolvedValueOnce(makeOfferings());
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(1500);
await jest.advanceTimersByTimeAsync(1500);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).toHaveBeenCalledWith(makeOfferings());
});
test('BUG FIX: retries when offerings load but current is null', async () => {
mockGetOfferings
.mockResolvedValueOnce(makeOfferings(false))
.mockResolvedValueOnce(makeOfferings(false))
.mockResolvedValueOnce(makeOfferings(true));
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(1500);
await jest.advanceTimersByTimeAsync(1500);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).toHaveBeenCalledWith(makeOfferings(true));
});
test('BUG FIX: does not call setOfferings when all retries fail', async () => {
mockGetOfferings.mockRejectedValue(new Error('permanent failure'));
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(3000);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).not.toHaveBeenCalled();
});
test('stops retrying early when current offering is found', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings(true));
const setOfferings = jest.fn();
await loadOfferings(setOfferings);
expect(mockGetOfferings).toHaveBeenCalledTimes(1);
expect(setOfferings).toHaveBeenCalledTimes(1);
});
});
describe('fetchSubscriptionStatus', () => {
test('sets subscribed true when server returns hasAccess true', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: true }),
});
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(true);
});
test('sets subscribed false when server returns hasAccess false', async () => {
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
test('sets subscribed false on network error', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('network'));
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
test('sets subscribed false on non-ok response', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false });
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
});
describe('getAvailablePackagesFromOfferings', () => {
test('returns packages from current offering', () => {
const offerings = makeOfferings();
const packages = getAvailablePackagesFromOfferings(offerings);
expect(packages).toHaveLength(2);
expect(packages[0].identifier).toBe('lifetime');
});
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
expect(() => getAvailablePackagesFromOfferings(null)).not.toThrow();
expect(getAvailablePackagesFromOfferings(null)).toEqual([]);
});
test('BUG FIX: returns [] when current is null (old code threw)', () => {
expect(() => getAvailablePackagesFromOfferings(makeOfferings(false))).not.toThrow();
expect(getAvailablePackagesFromOfferings(makeOfferings(false))).toEqual([]);
});
});
describe('getSubscriptionsFromOfferings', () => {
test('filters by SUBSCRIPTION category', () => {
const subs = getSubscriptionsFromOfferings(makeOfferings());
expect(subs).toHaveLength(1);
expect(subs[0].identifier).toBe('lifetime');
});
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
expect(() => getSubscriptionsFromOfferings(null)).not.toThrow();
expect(getSubscriptionsFromOfferings(null)).toEqual([]);
});
});
describe('executePurchase', () => {
test('calls SDK and returns success with customerInfo', async () => {
const customerInfo = { entitlements: { active: { pro: {} } } };
mockPurchasePackage.mockResolvedValue({ customerInfo });
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(mockPurchasePackage).toHaveBeenCalledWith({ identifier: 'test' });
expect(result.success).toBe(true);
expect(result.customerInfo).toBe(customerInfo);
});
test('returns cancelled when user cancels', async () => {
mockPurchasePackage.mockRejectedValue({ userCancelled: true });
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(result).toEqual({ success: false, cancelled: true });
});
test('returns failure on error', async () => {
mockPurchasePackage.mockRejectedValue(new Error('payment failed'));
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(result).toEqual({ success: false, cancelled: false });
});
test('refreshes subscription status after purchase', async () => {
mockPurchasePackage.mockResolvedValue({
customerInfo: { entitlements: { active: {} } },
});
const setIsSubscribed = jest.fn();
await executePurchase({ pkg: { identifier: 'test' }, setIsSubscribed });
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
});
describe('executeRestore', () => {
test('BUG FIX: restorePurchases works (App Store Guideline 3.1.1)', async () => {
const customerInfo = { entitlements: { active: { premium: {} } } };
mockRestorePurchases.mockResolvedValue(customerInfo);
const result = await executeRestore(jest.fn());
expect(mockRestorePurchases).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.customerInfo).toBe(customerInfo);
});
test('returns success false when no active entitlements', async () => {
mockRestorePurchases.mockResolvedValue({ entitlements: { active: {} } });
const result = await executeRestore(jest.fn());
expect(result.success).toBe(false);
});
test('handles errors gracefully', async () => {
mockRestorePurchases.mockRejectedValue(new Error('network error'));
const result = await executeRestore(jest.fn());
expect(result).toEqual({ success: false });
});
test('refreshes subscription status after restore', async () => {
mockRestorePurchases.mockResolvedValue({
entitlements: { active: { pro: {} } },
});
await executeRestore(jest.fn());
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
});

View File

@@ -1,2 +0,0 @@
export { useInAppPurchase } from './useInAppPurchase';
export { useInAppPurchaseStore } from './store';

View File

@@ -1,19 +0,0 @@
import { create } from 'zustand';
interface InAppPurchaseState {
isReady: boolean;
offerings: any | null;
isSubscribed: boolean;
setIsSubscribed: (isSubscribed: boolean) => void;
setOfferings: (offerings: any | null) => void;
setIsReady: (isReady: boolean) => void;
}
export const useInAppPurchaseStore = create<InAppPurchaseState>((set) => ({
isReady: false,
offerings: null,
isSubscribed: false,
setIsSubscribed: (isSubscribed) => set({ isSubscribed }),
setOfferings: (offerings) => set({ offerings }),
setIsReady: (isReady) => set({ isReady }),
}));

View File

@@ -1,211 +0,0 @@
import Purchases, { LOG_LEVEL, PRODUCT_CATEGORY } from 'react-native-purchases';
import { Platform } from 'react-native';
import { useCallback, useRef, useState } from 'react';
import { useInAppPurchaseStore } from './store';
export const RETRY_ATTEMPTS = 3;
export const RETRY_DELAY_MS = 1500;
export const getRevenueCatAPIKey = (): string | undefined => {
if (process.env.EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT') {
return process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
}
return Platform.select({
ios: process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY,
android: process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY,
web: process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY,
});
};
export async function loadOfferings(setOfferings: (o: any) => void) {
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
try {
const result = await Purchases.getOfferings();
if (result?.current) {
setOfferings(result);
return;
}
console.warn(
`RevenueCat offerings loaded but no current offering (attempt ${attempt + 1}/${RETRY_ATTEMPTS})`
);
} catch (error) {
console.warn(
`Failed to load offerings (attempt ${attempt + 1}/${RETRY_ATTEMPTS}):`,
error
);
}
if (attempt < RETRY_ATTEMPTS - 1) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
}
export async function fetchSubscriptionStatus(
setIsSubscribed: (v: boolean) => void
) {
try {
const response = await fetch('/api/revenue-cat/get-subscription-status', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to check subscription status');
}
const data = await response.json();
setIsSubscribed(data.hasAccess);
} catch (error) {
console.error('Error fetching subscription status:', error);
setIsSubscribed(false);
}
}
export async function initiatePurchases({
isConfigured,
setIsReady,
setOfferings,
setIsSubscribed,
}: {
isConfigured: { current: boolean };
setIsReady: (v: boolean) => void;
setOfferings: (o: any) => void;
setIsSubscribed: (v: boolean) => void;
}) {
if (isConfigured.current) return;
try {
void Purchases.setLogLevel(LOG_LEVEL.INFO);
const apiKey = getRevenueCatAPIKey();
if (apiKey) {
Purchases.configure({ apiKey });
isConfigured.current = true;
await Promise.allSettled([
loadOfferings(setOfferings),
fetchSubscriptionStatus(setIsSubscribed),
]);
} else {
console.warn('No RevenueCat API key found for platform:', Platform.OS);
}
} catch (error) {
console.warn('Failed to initialize RevenueCat:', error);
} finally {
setIsReady(true);
}
}
export function getAvailablePackagesFromOfferings(offerings: any) {
const offering = offerings?.current;
if (!offering) {
return [];
}
return offering.availablePackages;
}
export function getSubscriptionsFromOfferings(offerings: any) {
return getAvailablePackagesFromOfferings(offerings).filter(
(pkg: any) =>
pkg.product.productCategory === PRODUCT_CATEGORY.SUBSCRIPTION
);
}
export async function executePurchase({
pkg,
setIsSubscribed,
}: {
pkg: any;
setIsSubscribed: (v: boolean) => void;
}) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
await fetchSubscriptionStatus(setIsSubscribed);
return { success: true, customerInfo };
} catch (error: any) {
if (error.userCancelled) {
return { success: false, cancelled: true };
}
console.error('Failed to purchase:', error);
return { success: false, cancelled: false };
}
}
export async function executeRestore(
setIsSubscribed: (v: boolean) => void
) {
try {
const customerInfo = await Purchases.restorePurchases();
await fetchSubscriptionStatus(setIsSubscribed);
return {
success: Object.keys(customerInfo.entitlements.active).length > 0,
customerInfo,
};
} catch (error) {
console.error('Failed to restore purchases:', error);
return { success: false };
}
}
export function useInAppPurchase() {
const {
isReady,
offerings,
setOfferings,
setIsSubscribed,
isSubscribed,
setIsReady,
} = useInAppPurchaseStore();
const [isPurchasing, setIsPurchasing] = useState(false);
const isConfigured = useRef(false);
const initiate = useCallback(
() =>
initiatePurchases({
isConfigured,
setIsReady,
setOfferings,
setIsSubscribed,
}),
[setIsReady, setOfferings, setIsSubscribed]
);
const getAvailablePackages = useCallback(
() => getAvailablePackagesFromOfferings(offerings),
[offerings]
);
const getAvailableSubscriptions = useCallback(
() => getSubscriptionsFromOfferings(offerings),
[offerings]
);
const purchasePackage = useCallback(
async ({ pkg }: { pkg: any }) => {
setIsPurchasing(true);
try {
return await executePurchase({ pkg, setIsSubscribed });
} finally {
setIsPurchasing(false);
}
},
[setIsPurchasing, setIsSubscribed]
);
const restorePurchases = useCallback(async () => {
setIsPurchasing(true);
try {
return await executeRestore(setIsSubscribed);
} finally {
setIsPurchasing(false);
}
}, [setIsPurchasing, setIsSubscribed]);
return {
isReady,
offerings,
isSubscribed,
isPurchasing,
initiate,
getAvailablePackages,
getAvailableSubscriptions,
purchasePackage,
restorePurchases,
};
}
export default useInAppPurchase;

View File

@@ -1,34 +0,0 @@
import React from 'react';
interface UseHandleStreamResponseProps {
onChunk: (content: string) => void;
onFinish: (content: string) => void;
}
export function useHandleStreamResponse({ onChunk, onFinish }: UseHandleStreamResponseProps) {
const handleStreamResponse = React.useCallback(
async (response: Response) => {
if (response.body) {
const reader = response.body.getReader();
if (reader) {
const decoder = new TextDecoder();
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
onFinish(content);
break;
}
const chunk = decoder.decode(value, { stream: true });
content += chunk;
onChunk(content);
}
}
}
},
[onChunk, onFinish]
);
return handleStreamResponse;
}
export default useHandleStreamResponse;

View File

@@ -1,35 +0,0 @@
import { useFocusEffect } from '@react-navigation/native';
import { useNavigation } from 'expo-router';
import { BackHandler } from 'react-native';
export const usePreventBack = (): void => {
const navigation = useNavigation();
useFocusEffect(() => {
navigation.setOptions({
headerLeft: () => null,
gestureEnabled: false,
});
navigation.getParent()?.setOptions({ gestureEnabled: false });
// Android back button handler
const hardwareBackPressHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
// Prevent default behavior of leaving the screen
return true;
}
);
return () => {
navigation.getParent()?.setOptions({ gestureEnabled: true });
navigation.setOptions({
gestureEnabled: true,
});
hardwareBackPressHandler.remove();
};
});
};
export default usePreventBack;

View File

@@ -1,149 +0,0 @@
import type { ReactNativeAsset } from '@uploadcare/upload-client';
import * as SecureStore from 'expo-secure-store';
import * as React from 'react';
interface UploadInputReactNative {
reactNativeAsset: ReactNativeAsset & { file?: File };
}
interface UploadInputUrl {
url: string;
}
interface UploadInputBase64 {
base64: string;
}
interface UploadInputBuffer {
buffer: Buffer;
}
type UploadInput = UploadInputReactNative | UploadInputUrl | UploadInputBase64 | UploadInputBuffer;
interface UploadResult {
url?: string;
mimeType?: string | null;
error?: string;
}
interface UploadHookResult {
loading: boolean;
}
// Both paths upload via the proxy's /_create/api/upload/ (respects S3 flag).
// Web: globalThis.fetch with full proxy URL + no custom headers (avoids CORS
// preflight — the proxy adds project-group-id from the hostname server-side).
// Native: FileSystem.uploadAsync to same URL with manual auth headers.
function useUpload(): [(input: UploadInput) => Promise<UploadResult>, UploadHookResult] {
const [loading, setLoading] = React.useState(false);
const upload = React.useCallback(async (input: UploadInput): Promise<UploadResult> => {
try {
setLoading(true);
let response: Response | undefined;
if ('reactNativeAsset' in input && input.reactNativeAsset) {
const asset = input.reactNativeAsset;
if (asset.file) {
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const formData = new FormData();
formData.append('file', asset.file);
response = await globalThis.fetch(`${proxyBaseUrl}/_create/api/upload/`, {
method: 'POST',
body: formData,
});
} else {
const FileSystem = require('expo-file-system/legacy');
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
const host = process.env.EXPO_PUBLIC_HOST;
const headers: Record<string, string> = {
'x-createxyz-project-group-id': projectGroupId || '',
'host': host || '',
'x-forwarded-host': host || '',
'x-createxyz-host': host || '',
};
try {
const authStr = await SecureStore.getItemAsync(`${projectGroupId}-jwt`);
if (authStr) {
const auth = JSON.parse(authStr);
if (auth?.jwt) headers['authorization'] = `Bearer ${auth.jwt}`;
}
} catch {}
const uploadResult = await FileSystem.uploadAsync(
`${proxyBaseUrl}/_create/api/upload/`,
asset.uri,
{
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
fieldName: 'file',
headers,
},
);
if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error(`Upload failed (${uploadResult.status}): ${uploadResult.body}`);
}
let data: { url?: string; mimeType?: string | null };
try {
data = JSON.parse(uploadResult.body);
} catch {
throw new Error('Upload failed: invalid response from upload service');
}
return { url: data.url, mimeType: data.mimeType || null };
}
} else if ('url' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: input.url }),
});
} else if ('base64' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ base64: input.base64 }),
});
} else if ('buffer' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
},
body: input.buffer as unknown as BodyInit,
});
}
if (!response || !response.ok) {
if (response?.status === 413) {
throw new Error('Upload failed: File too large.');
}
const body = await response?.text().catch(() => '');
throw new Error(`Upload failed (${response?.status ?? 'no response'}): ${body}`);
}
const data = await response.json();
return { url: data.url, mimeType: data.mimeType || null };
} catch (uploadError) {
if (uploadError instanceof Error) {
return { error: uploadError.message };
}
if (typeof uploadError === 'string') {
return { error: uploadError };
}
return { error: 'Upload failed' };
} finally {
setLoading(false);
}
}, []);
return [upload, { loading }];
}
export { useUpload };
export default useUpload;

View File

@@ -1,7 +0,0 @@
import config from '@anythingai/app/tailwind.config';
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./node_modules/@anythingai/app/**/*.{js,ts,jsx,tsx}'],
...config,
};

View File

@@ -1,17 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

16
apps/web/.gitignore vendored
View File

@@ -1,16 +0,0 @@
.DS_Store
/node_modules/
# Next.js
/.next/
/out/
next-env.d.ts
# Environment
.env
.env.local
.env.production.local
.env.development.local
# TypeScript
*.tsbuildinfo

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -1,35 +0,0 @@
-- Database schema for the insole-production time tracker (apps/web backend).
--
-- This file was reverse-engineered from the API route queries — the original
-- Create/Anything export shipped no migration. Run it once against a fresh
-- Postgres database (Neon or local) to create the two application tables the
-- app needs:
--
-- psql "$DATABASE_URL" -f apps/web/db/schema.sql
--
-- Auth (better-auth user/session/account/verification tables) is NOT created
-- here. The core flow — tasks, stopwatch, history, CSV export — does not check
-- a session, so auth is optional for local development. If you do want sign-in,
-- generate those tables separately with: npx @better-auth/cli migrate
CREATE TABLE IF NOT EXISTS production_tasks (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
insole_types text[] NOT NULL DEFAULT ARRAY['Kurk', 'Berk', '3D']::text[],
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS time_logs (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE,
start_time timestamptz NOT NULL,
end_time timestamptz,
duration_seconds integer NOT NULL DEFAULT 0,
pair_count integer NOT NULL DEFAULT 2, -- "number of insoles" for the session
insole_type text, -- 'Kurk' | 'Berk' | '3D'
notes text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS time_logs_task_id_idx ON time_logs (task_id);
CREATE INDEX IF NOT EXISTS time_logs_start_time_idx ON time_logs (start_time DESC);

View File

@@ -1,5 +0,0 @@
declare module '*.css' {}
declare module 'vitest/config' {
export function defineConfig(config: Record<string, any>): Record<string, any>;
}

View File

@@ -1,28 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
devIndicators: false,
typescript: {
ignoreBuildErrors: true,
},
env: {
NEXT_PUBLIC_CREATE_BASE_URL: process.env.NEXT_PUBLIC_CREATE_BASE_URL,
NEXT_PUBLIC_CREATE_HOST: process.env.NEXT_PUBLIC_CREATE_HOST,
NEXT_PUBLIC_PROJECT_GROUP_ID: process.env.NEXT_PUBLIC_PROJECT_GROUP_ID,
},
serverExternalPackages: [
'@neondatabase/serverless',
'ws',
'@better-auth/kysely-adapter',
'kysely',
],
rewrites() {
return [
{
source: '/fontawesome/:path*',
destination: 'https://ka-p.fontawesome.com/:path*',
},
];
},
};
module.exports = nextConfig;

View File

@@ -1,48 +0,0 @@
{
"name": "web",
"private": true,
"scripts": {
"dev": "next dev --port 4000",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@neondatabase/serverless": "^0.10.4",
"@opentelemetry/api": "^1.9.0",
"@tanstack/react-query": "^5.72.2",
"argon2-wasm-edge": "^1.0.23",
"better-auth": "^1.1.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.0.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.0.4",
"react-hook-form": "^7.72.0",
"recharts": "3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.2.0",
"vanilla-colorful": "^0.7.2",
"vaul": "^1.1.2",
"ws": "^8.18.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/ws": "^8.18.1",
"jsdom": "^26.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
}
}

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

Binary file not shown.

View File

@@ -1,59 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth scaffolding. The useEffect-on-mount → authClient.signOut →
* window.location.href redirect is load-bearing for the mobile WebView's
* "sign out" flow. Safe to restyle the spinner / copy; unsafe to bypass
* authClient.signOut or change the redirect behavior.
*/
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { authClient } from '@/lib/auth-client';
function LogoutHandler() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const { error: signOutError } = await authClient.signOut();
if (cancelled) return;
if (signOutError) {
setError(signOutError.message ?? 'Sign out failed');
return;
}
if (typeof window !== 'undefined') {
window.location.href = callbackUrl;
} else {
console.warn('logout: window is undefined; cannot redirect to callbackUrl');
}
})();
return () => {
cancelled = true;
};
}, [callbackUrl]);
return (
<main className="flex min-h-screen w-full items-center justify-center bg-gray-50 p-[16px]">
<div className="flex flex-col items-center gap-[12px] text-[14px] text-gray-600">
{error ? (
<span className="text-red-600">{error}</span>
) : (
<span>Signing out</span>
)}
</div>
</main>
);
}
export default function LogoutPage() {
return (
<Suspense>
<LogoutHandler />
</Suspense>
);
}

View File

@@ -1,118 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth scaffolding. Same contract as signup/page.tsx: <form
* onSubmit>, e.preventDefault(), and window.location.href redirect are all
* load-bearing for the mobile WebView. DO NOT replace <form onSubmit> with
* <button onClick> — that broke signin platform-wide in a prior AI rewrite.
*
* Safe: restyle, rewrite copy, add form fields.
* Unsafe: replacing <form>, removing preventDefault, bypassing
* authClient.signIn.email, changing the callbackUrl redirect.
*/
"use client";
import { useSearchParams } from "next/navigation";
import { type FormEvent, Suspense, useState } from "react";
import { SocialSignInButtons } from "@/components/SocialSignInButtons";
import { authClient } from "@/lib/auth-client";
function SignInForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error: signInError } = await authClient.signIn.email({
email,
password,
});
if (signInError) {
setError(signInError.message ?? "Sign in failed");
setLoading(false);
return;
}
if (typeof window !== "undefined") {
window.location.href = callbackUrl;
} else {
console.warn(
"signin: window is undefined; cannot redirect to callbackUrl",
);
}
};
return (
<main className="flex min-h-screen w-full items-center justify-center bg-gray-50 p-[16px]">
<form
onSubmit={(e) => {
void onSubmit(e);
}}
className="flex w-full max-w-[400px] flex-col gap-[16px] rounded-[12px] bg-white p-[24px] shadow"
>
<h1 className="text-[24px] font-semibold">Sign in</h1>
<label className="flex flex-col gap-[4px] text-[14px]">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
<label className="flex flex-col gap-[4px] text-[14px]">
Password
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
{error && (
<div className="rounded-[8px] bg-red-50 p-[10px] text-[14px] text-red-600">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="rounded-[8px] bg-blue-600 p-[12px] text-[16px] font-medium text-white disabled:opacity-50"
>
{loading ? "Signing in…" : "Sign In"}
</button>
<SocialSignInButtons callbackUrl={callbackUrl} />
<a
href={`/account/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
className="text-center text-[14px] text-blue-600 hover:underline"
>
No account? Sign up
</a>
</form>
</main>
);
}
export default function SignInPage() {
return (
<Suspense>
<SignInForm />
</Suspense>
);
}

View File

@@ -1,124 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth scaffolding. The <form onSubmit>, e.preventDefault(), and
* window.location.href redirect are load-bearing for the mobile WebView auth
* flow (AuthWebView intercepts the navigation to capture the session). A
* prior AI rewrite replaced <form onSubmit> with <button onClick> and broke
* signup platform-wide — "credentials cleared" / "button does nothing" for
* every user until a human reverted it. DO NOT repeat that mistake.
*
* Safe: restyle, rewrite copy, add form fields (pass `name` explicitly).
* Unsafe: replacing <form>, removing preventDefault, bypassing
* authClient.signUp.email, changing the callbackUrl redirect.
*/
"use client";
import { useSearchParams } from "next/navigation";
import { type FormEvent, Suspense, useState } from "react";
import { SocialSignInButtons } from "@/components/SocialSignInButtons";
import { authClient } from "@/lib/auth-client";
function SignUpForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
// The server backfills `name` from the email local-part when it's missing,
// so email + password is enough.
const { error: signUpError } = await authClient.signUp.email({
email,
password,
name: "",
});
if (signUpError) {
setError(signUpError.message ?? "Sign up failed");
setLoading(false);
return;
}
if (typeof window !== "undefined") {
window.location.href = callbackUrl;
} else {
console.warn(
"signup: window is undefined; cannot redirect to callbackUrl",
);
}
};
return (
<main className="flex min-h-screen w-full items-center justify-center bg-gray-50 p-[16px]">
<form
onSubmit={(e) => {
void onSubmit(e);
}}
className="flex w-full max-w-[400px] flex-col gap-[16px] rounded-[12px] bg-white p-[24px] shadow"
>
<h1 className="text-[24px] font-semibold">Create account</h1>
<label className="flex flex-col gap-[4px] text-[14px]">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
<label className="flex flex-col gap-[4px] text-[14px]">
Password
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
{error && (
<div className="rounded-[8px] bg-red-50 p-[10px] text-[14px] text-red-600">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="rounded-[8px] bg-blue-600 p-[12px] text-[16px] font-medium text-white disabled:opacity-50"
>
{loading ? "Creating account…" : "Sign Up"}
</button>
<SocialSignInButtons callbackUrl={callbackUrl} />
<a
href={`/account/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`}
className="text-center text-[14px] text-blue-600 hover:underline"
>
Already have an account? Sign in
</a>
</form>
</main>
);
}
export default function SignUpPage() {
return (
<Suspense>
<SignUpForm />
</Suspense>
);
}

View File

@@ -1,168 +0,0 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Dev-only simulated social sign-in. Real OAuth (Google/Apple) cannot run
* inside the builder's cross-origin preview iframe — and each sandbox has a
* throwaway URL that can't be registered as an OAuth redirect — so the social
* buttons on signin/signup redirect here during development instead of hitting
* the real provider. This page mints a real session via the shipped
* email/password flow (the same flow that already works in the preview iframe),
* so generated apps can exercise their authenticated UI without configuring
* OAuth. In production (NEXT_PUBLIC_CREATE_ENV !== 'DEVELOPMENT') this page is
* inert and the buttons run the real provider OAuth.
*/
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { type FormEvent, Suspense, useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";
const isDev = process.env.NEXT_PUBLIC_CREATE_ENV === "DEVELOPMENT";
const PROVIDER_LABELS: Record<string, string> = {
google: "Google",
apple: "Apple",
};
// Stable per-email password so repeat dev sign-ins reuse the same account.
// Only ever used in development; never reaches production.
const devPasswordForEmail = (email: string) => `dev-social-${email}`;
function SocialDevShim() {
const router = useRouter();
const searchParams = useSearchParams();
const provider = searchParams.get("provider") || "google";
const callbackUrl = searchParams.get("callbackUrl") || "/";
const label = PROVIDER_LABELS[provider] ?? provider;
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [missingSecrets, setMissingSecrets] = useState<string[]>([]);
useEffect(() => {
if (!isDev) {
router.replace("/");
}
}, [router]);
useEffect(() => {
fetch(
`/api/__create/check-social-secrets?provider=${encodeURIComponent(provider)}`,
)
.then((r) => r.json())
.then((data) => setMissingSecrets(data.missing ?? []))
.catch((err) => {
console.error("Failed to check social secrets:", err);
});
}, [provider]);
if (!isDev) {
return null;
}
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const password = devPasswordForEmail(email);
// Returning dev user → sign in. First-time → sign up. The server backfills
// `name` from the email when omitted, so an empty name is fine.
const { error: signInError } = await authClient.signIn.email({
email,
password,
});
if (signInError) {
const { error: signUpError } = await authClient.signUp.email({
email,
password,
name,
});
if (signUpError) {
setError(signUpError.message ?? "Dev sign-in failed");
setLoading(false);
return;
}
}
if (typeof window !== "undefined") {
window.location.href = callbackUrl;
}
};
return (
<main className="flex min-h-screen w-full items-center justify-center bg-gray-50 p-[16px]">
<div className="flex w-full max-w-[400px] flex-col gap-[16px] rounded-[12px] bg-white p-[24px] shadow">
<div className="rounded-[8px] border border-amber-400 bg-amber-50 p-[10px] text-[13px] text-amber-800">
<strong>Development mode</strong> this is a simulated {label}{" "}
sign-in. In production, users will see the real {label} login screen.
</div>
{missingSecrets.length > 0 && (
<div className="rounded-[8px] border border-red-300 bg-red-50 p-[10px] text-[13px] text-red-700">
<strong>{label} isn't configured yet.</strong> Add these in project
settings → Authentication or {label} sign-in won't work once
published: {missingSecrets.join(", ")}
</div>
)}
<h1 className="text-[24px] font-semibold">Sign in with {label}</h1>
<form
onSubmit={(e) => {
void onSubmit(e);
}}
className="flex flex-col gap-[16px]"
>
<label className="flex flex-col gap-[4px] text-[14px]">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="test@example.com"
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
<label className="flex flex-col gap-[4px] text-[14px]">
Display name <span className="text-gray-400">(optional)</span>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Test User"
className="rounded-[8px] border border-gray-300 p-[10px] text-[16px] outline-none focus:border-blue-500"
/>
</label>
{error && (
<div className="rounded-[8px] bg-red-50 p-[10px] text-[14px] text-red-600">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="rounded-[8px] bg-gray-900 p-[12px] text-[16px] font-medium text-white disabled:opacity-50"
>
{loading ? "Signing in…" : `Continue as ${label} user`}
</button>
</form>
</div>
</main>
);
}
export default function SocialDevShimPage() {
return (
<Suspense>
<SocialDevShim />
</Suspense>
);
}

Some files were not shown because too many files have changed in this diff Show More