Initial commit: code as received (Create/Anything export)

Insole-production time tracker exported from the Create/Anything AI
platform. Baseline snapshot before any reverse-engineering or cleanup.

- apps/mobile: Expo Router app (iOS/Android/web), the only workspace
- publisher/: standalone OpenNext/AWS deploy tooling for the web side
- Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
Bas van Rossem
2026-06-17 10:19:33 +02:00
commit d94d0b188b
192 changed files with 50705 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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