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:
105
apps/mobile/src/__create/ErrorBoundary.tsx
Normal file
105
apps/mobile/src/__create/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { Component, type ReactNode } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function postErrorToParent(error: Error) {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'sandbox:error:detected',
|
||||
error: {
|
||||
message: error.message,
|
||||
name: error.name || 'Error',
|
||||
stack: error.stack || '',
|
||||
},
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function postErrorResolvedToParent() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'sandbox:error:resolved' }, '*');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
postErrorToParent(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Something went wrong</Text>
|
||||
<Text style={styles.message}>
|
||||
{this.state.error?.message ?? 'An unexpected error occurred'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
postErrorResolvedToParent();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Try again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#18191B',
|
||||
marginBottom: 8,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
color: '#959697',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#18191B',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
84
apps/mobile/src/__create/analytics.ts
Normal file
84
apps/mobile/src/__create/analytics.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const VISITOR_ID_KEY = "anything_analytics_visitor_id";
|
||||
|
||||
// Mirror the gating used by Sentry / the TestFlight logger: only emit from
|
||||
// real (production) builds, never from the in-builder dev runtime.
|
||||
function isActive(): boolean {
|
||||
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
|
||||
}
|
||||
|
||||
function generateVisitorId(): string {
|
||||
const rand = () => Math.random().toString(36).slice(2);
|
||||
return `${rand()}${rand()}`.slice(0, 32);
|
||||
}
|
||||
|
||||
let visitorIdPromise: Promise<string> | null = null;
|
||||
|
||||
// Stable, anonymous, per-install id. Not a secret, so AsyncStorage (not the
|
||||
// keychain) is the right home. Generated once and reused for the install.
|
||||
function getVisitorId(): Promise<string> {
|
||||
if (!visitorIdPromise) {
|
||||
visitorIdPromise = (async () => {
|
||||
try {
|
||||
const existing = await AsyncStorage.getItem(VISITOR_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const created = generateVisitorId();
|
||||
await AsyncStorage.setItem(VISITOR_ID_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
// If persistence fails, fall back to a session-scoped id so the
|
||||
// current run still attributes its views to one visitor.
|
||||
return generateVisitorId();
|
||||
}
|
||||
})();
|
||||
}
|
||||
return visitorIdPromise;
|
||||
}
|
||||
|
||||
// Records one screen view per route change. The endpoint enforces the global
|
||||
// flag and the project's analytics opt-in, dropping events (204) when off, so
|
||||
// this always fires and the server decides whether to keep it.
|
||||
export function ScreenViewTracker() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive()) return;
|
||||
|
||||
const endpoint = process.env.EXPO_PUBLIC_ANALYTICS_ENDPOINT;
|
||||
const host = process.env.EXPO_PUBLIC_HOST;
|
||||
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
||||
if (!endpoint || !host || !projectGroupId || !pathname) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const visitorId = await getVisitorId();
|
||||
if (cancelled) return;
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
d: host,
|
||||
p: pathname,
|
||||
pgid: projectGroupId,
|
||||
vid: visitorId,
|
||||
os: Platform.OS,
|
||||
dt: "mobile",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Analytics must never crash or block the host app.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
19
apps/mobile/src/__create/anything-menu.ios.tsx
Normal file
19
apps/mobile/src/__create/anything-menu.ios.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import LauncherMenuContainer from '@anythingai/app/screens/launcher-menu';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
const isExpoGo = globalThis.expo?.modules?.ExpoGo;
|
||||
|
||||
export default () => {
|
||||
if (isExpoGo) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{ ...StyleSheet.absoluteFillObject, zIndex: 9999 }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<LauncherMenuContainer />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
586
apps/mobile/src/__create/anything-menu.tsx
Normal file
586
apps/mobile/src/__create/anything-menu.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, memo, useRef, useReducer } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
PanResponder,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import Svg, {
|
||||
Path,
|
||||
Rect,
|
||||
Mask,
|
||||
Circle,
|
||||
G,
|
||||
Defs,
|
||||
ClipPath,
|
||||
Line,
|
||||
} from "react-native-svg";
|
||||
import { NativeModule, requireNativeModule } from "expo-modules-core";
|
||||
import { MotiView } from "moti";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
declare class AnythingLauncherModule extends NativeModule {
|
||||
open(url: string): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
isWeb(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const TINT_DURATION_MS = 3000;
|
||||
const CIRCLE_DIAMETER = 80;
|
||||
const GAP = 16;
|
||||
const ICON_SIZE = 18;
|
||||
|
||||
const getWebAppUrl = () => {
|
||||
return process.env.EXPO_PUBLIC_APP_URL ?? "";
|
||||
};
|
||||
|
||||
const isAnythingApp =
|
||||
Platform.OS !== "web" &&
|
||||
process.env.EXPO_PUBLIC_IS_ANYTHING_APP === JSON.stringify(true);
|
||||
|
||||
const AnythingLauncher = isAnythingApp
|
||||
? requireNativeModule<AnythingLauncherModule>("AnythingLauncherModule")
|
||||
: null;
|
||||
|
||||
const RefreshIcon = memo(() => {
|
||||
return (
|
||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
||||
<Path
|
||||
d="M1.5 7.5s1.504-2.049 2.725-3.271a6.75 6.75 0 11-1.712 6.646M1.5 7.5V3m0 4.5H6"
|
||||
stroke="#7E7F80"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
const CloseIcon = memo(() => {
|
||||
return (
|
||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
||||
<Path
|
||||
d="M2.25 15.75l13.5-13.5M15.75 15.75L2.25 2.25"
|
||||
stroke="#7E7F80"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
const MobileViewIcon = memo(({ color }: { color: string }) => {
|
||||
return (
|
||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
||||
<Path
|
||||
d="M11.8125 1.5H6.1875C5.15197 1.5 4.3125 2.33947 4.3125 3.375V14.625C4.3125 15.6605 5.15197 16.5 6.1875 16.5H11.8125C12.848 16.5 13.6875 15.6605 13.6875 14.625V3.375C13.6875 2.33947 12.848 1.5 11.8125 1.5Z"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Line
|
||||
x1={7.89575}
|
||||
y1={13.3832}
|
||||
x2={10.104}
|
||||
y2={13.3832}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
const WebViewIcon = memo(({ color }: { color: string }) => {
|
||||
return (
|
||||
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
|
||||
<G clipPath="url(#clip0_340_2754)">
|
||||
<Path
|
||||
d="M15 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V12C1.5 12.8284 2.17157 13.5 3 13.5H15C15.8284 13.5 16.5 12.8284 16.5 12V3C16.5 2.17157 15.8284 1.5 15 1.5Z"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 13.5V16.5"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M6 16.5H12"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_340_2754">
|
||||
<Rect width={18} height={18} fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
const ActiveDot = memo(() => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#000",
|
||||
borderRadius: 50,
|
||||
width: 4,
|
||||
height: 4,
|
||||
position: "absolute",
|
||||
bottom: -8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const InstructionsOverlay = memo(
|
||||
({
|
||||
showTint,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
showTint: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const r = CIRCLE_DIAMETER / 2;
|
||||
const totalWidth = CIRCLE_DIAMETER * 2 + GAP;
|
||||
const left = (width - totalWidth) / 2;
|
||||
const cx1 = left + r;
|
||||
const cx2 = cx1 + CIRCLE_DIAMETER + GAP;
|
||||
const cy = height / 2 + 64;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MotiView
|
||||
from={{ opacity: 0 }}
|
||||
animate={{ opacity: showTint ? 1 : 0 }}
|
||||
transition={{ type: "timing", duration: 350 }}
|
||||
style={menuStyles.holdTwoFingersTextContainer}
|
||||
>
|
||||
<Text style={menuStyles.holdTwoFingersText}>
|
||||
Hold with 2 fingers for menu
|
||||
</Text>
|
||||
</MotiView>
|
||||
<MotiView
|
||||
from={{ opacity: 0 }}
|
||||
animate={{ opacity: showTint ? 1 : 0 }}
|
||||
transition={{ type: "timing", duration: 350 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
||||
<Mask id="holes">
|
||||
<Rect x="0" y="0" width={width} height={height} fill="white" />
|
||||
<Circle cx={cx1} cy={cy} r={r} fill="black" />
|
||||
<Circle cx={cx2} cy={cy} r={r} fill="black" />
|
||||
</Mask>
|
||||
|
||||
<Rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={width}
|
||||
height={height}
|
||||
fill="black"
|
||||
opacity={0.8}
|
||||
mask="url(#holes)"
|
||||
/>
|
||||
</Svg>
|
||||
</MotiView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type State = {
|
||||
isLoading: boolean;
|
||||
showTint: boolean;
|
||||
showWebView: boolean;
|
||||
}
|
||||
|
||||
type Action = { type: 'INITIALIZE', payload: { showWebView: boolean, showTint: boolean } } | { type: 'TOGGLE_WEB_VIEW' } | { type: 'HIDE_TINT' }
|
||||
|
||||
const initialState: State = { isLoading: true, showTint: false, showWebView: false };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'INITIALIZE':
|
||||
return { ...state, ...action.payload, isLoading: false };
|
||||
case 'TOGGLE_WEB_VIEW':
|
||||
return { ...state, showWebView: !state.showWebView };
|
||||
case 'HIDE_TINT':
|
||||
return { ...state, showTint: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const AnythingMenu = isAnythingApp
|
||||
? ({ children }: { children: React.ReactNode }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
if (!AnythingLauncher) {
|
||||
throw new Error("AnythingLauncher is not available");
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Promise.all([
|
||||
AnythingLauncher.isWeb(),
|
||||
AsyncStorage.getItem("hasSeenOnboarding"),
|
||||
]).then(([isWeb, hasSeenOnboarding]) => {
|
||||
dispatch({ type: 'INITIALIZE', payload: { showWebView: Boolean(isWeb), showTint: hasSeenOnboarding !== 'true' } });
|
||||
}).catch(() => {
|
||||
dispatch({ type: 'INITIALIZE', payload: { showWebView: false, showTint: false } });
|
||||
});
|
||||
}
|
||||
}, [state.isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isLoading && state.showTint) {
|
||||
const timeout = setTimeout(() => {
|
||||
void AsyncStorage.setItem("hasSeenOnboarding", "true");
|
||||
dispatch({ type: 'HIDE_TINT' });
|
||||
}, TINT_DURATION_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
|
||||
}, [state.isLoading, state.showTint])
|
||||
|
||||
const menuProgress = useSharedValue(0);
|
||||
|
||||
const hideMenuOffset = -(44 + 36 + insets.top + 10);
|
||||
|
||||
const exitApp = useCallback(() => {
|
||||
void AnythingLauncher?.reset();
|
||||
}, []);
|
||||
|
||||
const reloadApp = useCallback(() => {
|
||||
void AnythingLauncher?.reload();
|
||||
}, []);
|
||||
|
||||
const toggleWebView = useCallback(() => {
|
||||
dispatch({ type: 'TOGGLE_WEB_VIEW' });
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(menuProgress.value, [0, 1], [1, 0.9]);
|
||||
const shadowOpacity = interpolate(menuProgress.value, [0, 1], [0, 0.4]);
|
||||
const elevation = interpolate(menuProgress.value, [0, 1], [0, 8]);
|
||||
|
||||
return {
|
||||
transform: [{ scale }],
|
||||
shadowOpacity,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowRadius: 32,
|
||||
elevation,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const menuAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateY = interpolate(
|
||||
menuProgress.value,
|
||||
[0, 1],
|
||||
[hideMenuOffset, 0]
|
||||
);
|
||||
|
||||
return {
|
||||
transform: [{ translateY }],
|
||||
};
|
||||
}, [hideMenuOffset]);
|
||||
|
||||
const appPointerEvents = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: menuProgress.value === 1 ? "box-only" : "auto",
|
||||
};
|
||||
}, [menuProgress]);
|
||||
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: (evt, gestureState) => {
|
||||
if (menuProgress.value === 1) {
|
||||
menuProgress.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.ease,
|
||||
});
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (gestureState.numberActiveTouches === 2) {
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
menuProgress.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.ease,
|
||||
});
|
||||
longPressTimer.current = null;
|
||||
}, 500);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onPanResponderEnd: (_evt, _gestureState) => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[menuProgress.value]
|
||||
);
|
||||
|
||||
const menuHeaderStyle = useMemo(
|
||||
() => ({
|
||||
...menuStyles.menuHeader,
|
||||
marginTop: insets.top + 10,
|
||||
}),
|
||||
[insets.top]
|
||||
);
|
||||
|
||||
if (state.isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
style={[styles.fill, animatedStyle]}
|
||||
pointerEvents="box-none"
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<Animated.View style={[styles.fillContent, appPointerEvents]}>
|
||||
{!state.showWebView ? (
|
||||
children
|
||||
) : (
|
||||
<WebView
|
||||
source={{ uri: getWebAppUrl() }}
|
||||
style={[styles.webView, { paddingTop: insets.top }]}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.menuContainer, menuAnimatedStyle]}>
|
||||
<View style={menuStyles.menuContainerStyle}>
|
||||
<View style={menuHeaderStyle}>
|
||||
<View style={menuStyles.leftSection}>
|
||||
<TouchableOpacity
|
||||
onPress={toggleWebView}
|
||||
style={menuStyles.button}
|
||||
>
|
||||
<MobileViewIcon
|
||||
color={state.showWebView ? "#7E7F80" : "#18191B"}
|
||||
/>
|
||||
{!state.showWebView && <ActiveDot />}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={toggleWebView}
|
||||
style={menuStyles.button}
|
||||
>
|
||||
<WebViewIcon color={state.showWebView ? "#18191B" : "#7E7F80"} />
|
||||
{state.showWebView && <ActiveDot />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={menuStyles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={reloadApp}
|
||||
style={menuStyles.button}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={exitApp} style={menuStyles.button}>
|
||||
<CloseIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<InstructionsOverlay
|
||||
showTint={state.showTint}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
: ({ children }: { children: React.ReactNode }) => children;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
fillContent: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
menuContainer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
menuTouchable: {
|
||||
flex: 1,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: "white",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
webViewContainer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "#fff",
|
||||
zIndex: 2000,
|
||||
},
|
||||
webViewHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 18,
|
||||
backgroundColor: "#fff",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e0e0e0",
|
||||
},
|
||||
webViewTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#18191B",
|
||||
},
|
||||
webViewCloseButton: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const menuStyles = StyleSheet.create({
|
||||
menuContainerStyle: {
|
||||
backgroundColor: "#fff",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
menuHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 18,
|
||||
},
|
||||
appIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
marginRight: 20,
|
||||
},
|
||||
appTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#18191B",
|
||||
flex: 1,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 28,
|
||||
},
|
||||
button: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
leftSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 28,
|
||||
flex: 1,
|
||||
},
|
||||
holdTwoFingersTextContainer: {
|
||||
zIndex: 1,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
transform: [{ translateY: -24 }],
|
||||
},
|
||||
holdTwoFingersText: {
|
||||
fontSize: 28,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Screen({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AnythingMenu>{children}</AnythingMenu>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
106
apps/mobile/src/__create/fetch.ts
Normal file
106
apps/mobile/src/__create/fetch.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { fetch as expoFetch } from 'expo/fetch';
|
||||
|
||||
const originalFetch = fetch;
|
||||
const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
|
||||
|
||||
const getURLFromArgs = (...args: Parameters<typeof fetch>) => {
|
||||
const [urlArg] = args;
|
||||
if (typeof urlArg === 'string') {
|
||||
return urlArg;
|
||||
}
|
||||
if (urlArg instanceof Request) {
|
||||
return urlArg.url;
|
||||
}
|
||||
// URL type may not be in the fetch signature for all TS environments
|
||||
if (typeof urlArg === 'object' && urlArg !== null && 'href' in urlArg) {
|
||||
return (urlArg as URL).href;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isFileURL = (url: string) => {
|
||||
return url.startsWith('file://') || url.startsWith('data:');
|
||||
};
|
||||
|
||||
const isStaticAssetURL = (url: string) => {
|
||||
return /\.(wasm|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)(\?|$)/i.test(url);
|
||||
};
|
||||
|
||||
const isFirstPartyURL = (url: string) => {
|
||||
return (
|
||||
url.startsWith('/') ||
|
||||
(process.env.EXPO_PUBLIC_BASE_URL && url.startsWith(process.env.EXPO_PUBLIC_BASE_URL))
|
||||
);
|
||||
};
|
||||
|
||||
const isSecondPartyURL = (url: string) => {
|
||||
return url.startsWith('/_create/');
|
||||
};
|
||||
|
||||
type Params = Parameters<typeof expoFetch>;
|
||||
const fetchToWeb = async function fetchWithHeaders(...args: Params) {
|
||||
const firstPartyURL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
const secondPartyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
||||
if (!firstPartyURL || !secondPartyURL) {
|
||||
return expoFetch(...args);
|
||||
}
|
||||
const [input, init] = args;
|
||||
const url = getURLFromArgs(input, init);
|
||||
if (!url) {
|
||||
return expoFetch(input, init);
|
||||
}
|
||||
|
||||
if (isFileURL(url) || isStaticAssetURL(url)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const isExternalFetch = !isFirstPartyURL(url);
|
||||
// we should not add headers to requests that don't go to our own server
|
||||
if (isExternalFetch) {
|
||||
return expoFetch(input, init);
|
||||
}
|
||||
|
||||
let finalInput = input;
|
||||
const baseURL = isSecondPartyURL(url) ? secondPartyURL : firstPartyURL;
|
||||
if (typeof input === 'string') {
|
||||
finalInput = input.startsWith('/') ? `${baseURL}${input}` : input;
|
||||
} else {
|
||||
return expoFetch(input, init);
|
||||
}
|
||||
|
||||
const initHeaders = init?.headers ?? {};
|
||||
const finalHeaders = new Headers(initHeaders);
|
||||
|
||||
const headers = {
|
||||
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
|
||||
host: process.env.EXPO_PUBLIC_HOST,
|
||||
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST,
|
||||
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value) {
|
||||
finalHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const auth = await SecureStore.getItemAsync(authKey)
|
||||
.then((auth) => {
|
||||
return auth ? JSON.parse(auth) : null;
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (auth) {
|
||||
finalHeaders.set('authorization', `Bearer ${auth.jwt}`);
|
||||
}
|
||||
|
||||
return expoFetch(finalInput, {
|
||||
...init,
|
||||
headers: finalHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
export default fetchToWeb;
|
||||
20
apps/mobile/src/__create/placeholder.svg
Normal file
20
apps/mobile/src/__create/placeholder.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width={128} height={128} viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="895" height="895" rx="19" fill="#E9E7E7" />
|
||||
<g stroke="#C0C0C0" stroke-width="1.00975">
|
||||
<line x1="447.505" y1="-23" x2="447.505" y2="901" />
|
||||
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505" />
|
||||
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068" />
|
||||
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504" />
|
||||
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061" />
|
||||
<line x1="282.495" y1="907" x2="282.495" y2="-30" />
|
||||
<line x1="611.495" y1="907" x2="611.495" y2="-30" />
|
||||
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061" />
|
||||
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53" />
|
||||
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612" />
|
||||
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643" />
|
||||
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772" />
|
||||
<circle cx="447.5" cy="441.5" r="163.995" />
|
||||
<circle cx="447.911" cy="447.911" r="237.407" />
|
||||
<circle cx="448" cy="442" r="384.495" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
apps/mobile/src/__create/polyfills.ts
Normal file
3
apps/mobile/src/__create/polyfills.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import updatedFetch from './fetch';
|
||||
// @ts-expect-error -- updatedFetch wraps the native fetch with custom headers
|
||||
global.fetch = updatedFetch;
|
||||
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Timer, History, Settings } from 'lucide-react-native';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
paddingTop: 4,
|
||||
},
|
||||
tabBarActiveTintColor: '#2563EB',
|
||||
tabBarInactiveTintColor: '#6B7280',
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Stopwatch',
|
||||
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Geschiedenis',
|
||||
tabBarIcon: ({ color }) => <History color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="tasks"
|
||||
options={{
|
||||
title: 'Instellingen',
|
||||
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const { data: logs = [], isLoading } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
|
||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
|
||||
const supported = await Linking.canOpenURL(exportUrl);
|
||||
if (supported) {
|
||||
await Linking.openURL(exportUrl);
|
||||
} else {
|
||||
Alert.alert('Fout', 'Kan de export-URL niet openen');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hrs > 0) return `${hrs}h ${mins}m`;
|
||||
if (mins > 0) return `${mins}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Geschiedenis
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleExport}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Download color="#2563EB" size={16} />
|
||||
<Text
|
||||
style={{
|
||||
color: '#2563EB',
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Exporteer CSV
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }}>
|
||||
{logs.length === 0 && !isLoading ? (
|
||||
<View style={{ alignItems: 'center', marginTop: 100 }}>
|
||||
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
|
||||
Nog geen opgeslagen sessies.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
logs.map((log: any) => (
|
||||
<View
|
||||
key={log.id}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.task_name}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
<Calendar color="#6B7280" size={12} />
|
||||
<Text
|
||||
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
{formatDate(log.start_time)} • {formatTime(log.start_time)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
{log.insole_type && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.insole_type}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{log.pair_count != null && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#BFDBFE',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Layers color="#2563EB" size={12} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#2563EB',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Clock color="#111827" size={12} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{formatDuration(log.duration_seconds)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Modal,
|
||||
Animated,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
|
||||
|
||||
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
||||
type InsoleType = (typeof INSOLE_TYPES)[number];
|
||||
|
||||
export default function TimerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
// fontError: if fonts fail to load on Android we still render (no freeze)
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
|
||||
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [discardPending, setDiscardPending] = useState(false);
|
||||
const [insoleCount, setInsoleCount] = useState(2);
|
||||
const [insoleCountText, setInsoleCountText] = useState('2');
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
||||
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const saveLogMutation = useMutation({
|
||||
mutationFn: async (log: any) => {
|
||||
const res = await fetch(`${BASE_URL}/api/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(log),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save log');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning && !isPaused) {
|
||||
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [isRunning, isPaused]);
|
||||
|
||||
const openPicker = () => {
|
||||
setShowPicker(true);
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const closePicker = () => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: SHEET_HEIGHT,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => setShowPicker(false));
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!activeTaskId) return;
|
||||
setIsRunning(true);
|
||||
setIsPaused(false);
|
||||
setStartTime(new Date());
|
||||
};
|
||||
|
||||
const handlePause = () => setIsPaused(true);
|
||||
const handleResume = () => setIsPaused(false);
|
||||
|
||||
const handleStop = () => {
|
||||
if (!activeTaskId || !startTime) return;
|
||||
setIsRunning(false);
|
||||
setIsPaused(false);
|
||||
const endTime = new Date();
|
||||
saveLogMutation.mutate({
|
||||
task_id: activeTaskId,
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
duration_seconds: elapsedTime,
|
||||
pair_count: insoleCount,
|
||||
insole_type: insoleType,
|
||||
});
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setDiscardPending(false);
|
||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (!discardPending) {
|
||||
setDiscardPending(true);
|
||||
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
|
||||
} else {
|
||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
||||
setIsRunning(false);
|
||||
setIsPaused(false);
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setDiscardPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsoleCountChange = (text: string) => {
|
||||
setInsoleCountText(text);
|
||||
const parsed = parseInt(text, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
|
||||
};
|
||||
|
||||
const adjustInsoleCount = (delta: number) => {
|
||||
const next = Math.max(1, insoleCount + delta);
|
||||
setInsoleCount(next);
|
||||
setInsoleCountText(String(next));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
const regular = fontError ? undefined : 'Inter_400Regular';
|
||||
const semibold = fontError ? undefined : 'Inter_600SemiBold';
|
||||
|
||||
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
|
||||
const canStart = !!activeTaskId;
|
||||
|
||||
const filteredTasks = tasks.filter((t: any) =>
|
||||
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 24 }}>
|
||||
{/* 1. Type zool */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Type zool
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{INSOLE_TYPES.map((type, i) => {
|
||||
const selected = insoleType === type;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type}
|
||||
onPress={() => {
|
||||
if (isRunning) return;
|
||||
setInsoleType(type);
|
||||
setActiveTaskId(null);
|
||||
}}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: selected ? '#2563EB' : '#E5E7EB',
|
||||
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 2. Type handeling */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Type handeling
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => !isRunning && openPicker()}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: activeTaskId ? '#111827' : '#9CA3AF',
|
||||
fontFamily: regular,
|
||||
}}
|
||||
>
|
||||
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
|
||||
</Text>
|
||||
<ChevronDown color="#6B7280" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 3. Aantal zolen */}
|
||||
<View style={{ marginBottom: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Aantal zolen
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => adjustInsoleCount(-1)}
|
||||
disabled={isRunning || insoleCount <= 1}
|
||||
style={{
|
||||
width: 64,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
|
||||
fontFamily: semibold,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
−
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
value={insoleCountText}
|
||||
onChangeText={handleInsoleCountChange}
|
||||
keyboardType="number-pad"
|
||||
editable={!isRunning}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: isRunning ? '#9CA3AF' : '#111827',
|
||||
fontFamily: semibold,
|
||||
paddingVertical: Platform.OS === 'android' ? 10 : 14,
|
||||
paddingHorizontal: 0,
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => adjustInsoleCount(1)}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
width: 64,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
color: isRunning ? '#D1D5DB' : '#111827',
|
||||
fontFamily: semibold,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 4. Stopwatch display */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!isRunning && canStart) handleStart();
|
||||
else if (isRunning) {
|
||||
if (isPaused) handleResume();
|
||||
else handlePause();
|
||||
}
|
||||
}}
|
||||
activeOpacity={canStart || isRunning ? 0.75 : 1}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 64,
|
||||
fontWeight: '600',
|
||||
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
|
||||
fontFamily: semibold,
|
||||
letterSpacing: -2,
|
||||
}}
|
||||
>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isPaused ? '#D97706' : '#2563EB',
|
||||
fontWeight: '500',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
|
||||
</Text>
|
||||
</View>
|
||||
) : canStart ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#2563EB',
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
|
||||
>
|
||||
Tik om te starten
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 5. Knoppen */}
|
||||
<View style={{ marginTop: 40 }}>
|
||||
{!isRunning ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleStart}
|
||||
disabled={!canStart}
|
||||
style={{
|
||||
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Play
|
||||
fill={canStart ? 'white' : '#9CA3AF'}
|
||||
color={canStart ? 'white' : '#9CA3AF'}
|
||||
size={24}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: canStart ? 'white' : '#9CA3AF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Start Stopwatch
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
style={{
|
||||
backgroundColor: '#DC2626',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
|
||||
<Text
|
||||
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
|
||||
>
|
||||
Stop & Opslaan
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDiscard}
|
||||
style={{
|
||||
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: discardPending ? '#ffffff' : '#6B7280',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
|
||||
<Modal
|
||||
visible={showPicker}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={closePicker}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Backdrop */}
|
||||
<Pressable
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
}}
|
||||
onPress={closePicker}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: SHEET_HEIGHT,
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
|
||||
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
|
||||
</View>
|
||||
|
||||
{/* Sheet header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
|
||||
>
|
||||
Type handeling
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
|
||||
Kies een handeling
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Task list */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
fontFamily: regular,
|
||||
}}
|
||||
>
|
||||
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
|
||||
Instellingen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
filteredTasks.map((task: any) => {
|
||||
const selected = activeTaskId === task.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={task.id}
|
||||
onPress={() => {
|
||||
setActiveTaskId(task.id);
|
||||
closePicker();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 18,
|
||||
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: selected ? '#2563EB' : '#374151',
|
||||
fontFamily: selected ? semibold : regular,
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
{selected && <Check size={20} color="#2563EB" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
||||
type InsoleType = (typeof ALL_TYPES)[number];
|
||||
|
||||
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
|
||||
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
|
||||
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
|
||||
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
|
||||
};
|
||||
|
||||
function TypeToggle({
|
||||
type,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
type: InsoleType;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = TYPE_COLORS[type];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 999,
|
||||
borderWidth: 2,
|
||||
borderColor: selected ? c.border : '#E5E7EB',
|
||||
backgroundColor: selected ? c.bg : '#F9FAFB',
|
||||
}}
|
||||
>
|
||||
{selected && <Check size={13} color={c.text} />}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: selected ? c.text : '#9CA3AF',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: InsoleType }) {
|
||||
const c = TYPE_COLORS[type];
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const [newTaskName, setNewTaskName] = useState('');
|
||||
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const addTaskMutation = useMutation({
|
||||
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, insole_types }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setNewTaskName('');
|
||||
setNewTaskTypes(['Kurk', 'Berk', '3D']);
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
name,
|
||||
insole_types,
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
insole_types: string[];
|
||||
}) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, insole_types }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTaskMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleNewType = (type: InsoleType) => {
|
||||
setNewTaskTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEditType = (type: InsoleType) => {
|
||||
setEditingTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
|
||||
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
|
||||
};
|
||||
|
||||
const handleStartEdit = (task: any) => {
|
||||
setEditingId(task.id);
|
||||
setEditingName(task.name);
|
||||
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = () => {
|
||||
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
|
||||
updateTaskMutation.mutate({
|
||||
id: editingId,
|
||||
name: editingName.trim(),
|
||||
insole_types: editingTypes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
setEditingTypes([]);
|
||||
};
|
||||
|
||||
const handleDelete = (task: any) => {
|
||||
Alert.alert(
|
||||
'Taak verwijderen',
|
||||
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
|
||||
[
|
||||
{ text: 'Annuleren', style: 'cancel' },
|
||||
{
|
||||
text: 'Verwijderen',
|
||||
style: 'destructive',
|
||||
onPress: () => deleteTaskMutation.mutate(task.id),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: '#ffffff' }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={{ paddingTop: insets.top, flex: 1 }}>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Instellingen
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
Beheer handelingen per zooltype
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Add New Task */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
marginBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Nieuwe handeling toevoegen
|
||||
</Text>
|
||||
|
||||
{/* Name input */}
|
||||
<TextInput
|
||||
value={newTaskName}
|
||||
onChangeText={setNewTaskName}
|
||||
placeholder="Naam van de stap, bijv. Leerrand"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAddTask}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 11,
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Insole type toggles */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#9CA3AF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Van toepassing op
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<TypeToggle
|
||||
key={type}
|
||||
type={type}
|
||||
selected={newTaskTypes.includes(type)}
|
||||
onPress={() => toggleNewType(type)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddTask}
|
||||
disabled={
|
||||
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
|
||||
}
|
||||
style={{
|
||||
backgroundColor:
|
||||
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{addTaskMutation.isPending ? (
|
||||
<ActivityIndicator color="white" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Plus
|
||||
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
|
||||
size={18}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Stap toevoegen
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Task List */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Huidige stappen ({tasks.length})
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
|
||||
) : tasks.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', marginTop: 40 }}>
|
||||
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
|
||||
Nog geen stappen. Voeg er een toe hierboven.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
tasks.map((task: any) => {
|
||||
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
|
||||
const isEditing = editingId === task.id;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={task.id}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{/* Edit name */}
|
||||
<TextInput
|
||||
value={editingName}
|
||||
onChangeText={setEditingName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
paddingBottom: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit insole types */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#9CA3AF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Van toepassing op
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<TypeToggle
|
||||
key={type}
|
||||
type={type}
|
||||
selected={editingTypes.includes(type)}
|
||||
onPress={() => toggleEditType(type)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Confirm / Cancel */}
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirmEdit}
|
||||
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#DCFCE7',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{updateTaskMutation.isPending ? (
|
||||
<ActivityIndicator color="#16A34A" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Check size={16} color="#16A34A" />
|
||||
<Text
|
||||
style={{
|
||||
color: '#16A34A',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Opslaan
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleCancelEdit}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<X size={16} color="#6B7280" />
|
||||
<Text
|
||||
style={{
|
||||
color: '#6B7280',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Annuleren
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Task name + actions */}
|
||||
<View
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#374151',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleStartEdit(task)}
|
||||
style={{
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderRadius: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Pencil color="#2563EB" size={16} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(task)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
style={{
|
||||
backgroundColor: '#FEF2F2',
|
||||
borderRadius: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Trash2 color="#DC2626" size={16} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Insole type badges */}
|
||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||
{types.map((type) => (
|
||||
<TypeBadge key={type} type={type} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
436
apps/mobile/src/app/+not-found.tsx
Normal file
436
apps/mobile/src/app/+not-found.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
type RelativePathString,
|
||||
type SitemapType,
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useRouter,
|
||||
useSitemap,
|
||||
} from 'expo-router';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
interface ParentSitemap {
|
||||
expoPages?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
filePath: string;
|
||||
cleanRoute?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function NotFoundScreen() {
|
||||
const router = useRouter();
|
||||
const params = useGlobalSearchParams();
|
||||
const expoSitemap = useSitemap();
|
||||
const [sitemap, setSitemap] = useState<SitemapType | ParentSitemap | null>(expoSitemap);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data.type === 'sandbox:sitemap') {
|
||||
window.removeEventListener('message', handler);
|
||||
setSitemap(event.data.sitemap);
|
||||
}
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'sandbox:sitemap',
|
||||
},
|
||||
'*'
|
||||
);
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handler);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isExpoSitemap = sitemap === expoSitemap;
|
||||
const missingPath = params['not-found']?.[0] || '';
|
||||
|
||||
const availableRoutes = useMemo(() => {
|
||||
return (
|
||||
expoSitemap?.children?.filter(
|
||||
(child) =>
|
||||
child.href &&
|
||||
child.contextKey !== './auth.jsx' &&
|
||||
child.contextKey !== './auth.web.jsx' &&
|
||||
child.contextKey !== './+not-found.tsx' &&
|
||||
child.contextKey !== 'expo-router/build/views/Sitemap.js'
|
||||
) || []
|
||||
);
|
||||
}, [expoSitemap]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
const hasTabsIndex = expoSitemap?.children?.some(
|
||||
(child) =>
|
||||
child.contextKey === './(tabs)/_layout.jsx' &&
|
||||
child.children.some((child) => child.contextKey === './(tabs)/index.jsx')
|
||||
);
|
||||
if (isExpoSitemap) {
|
||||
if (hasTabsIndex) {
|
||||
router.replace('../(tabs)/index.jsx');
|
||||
} else {
|
||||
router.replace('../');
|
||||
}
|
||||
} else {
|
||||
router.replace('..');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (url: string) => {
|
||||
try {
|
||||
if (url) {
|
||||
router.push(url as RelativePathString);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePage = useCallback(() => {
|
||||
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'sandbox:web:create',
|
||||
path: missingPath,
|
||||
view: 'mobile',
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}, [missingPath]);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Page Not Found', headerShown: false }} />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={18} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.pathContainer}>
|
||||
<View style={styles.pathPrefix}>
|
||||
<Text style={styles.pathPrefixText}>/</Text>
|
||||
</View>
|
||||
<View style={styles.pathContent}>
|
||||
<Text style={styles.pathText} numberOfLines={1}>
|
||||
{missingPath}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.mainContent}>
|
||||
<Text style={styles.title}>Uh-oh! This screen doesn't exist (yet).</Text>
|
||||
|
||||
<Text style={styles.subtitle}>
|
||||
Looks like "<Text style={styles.boldText}>/{missingPath}</Text>" isn't part of your
|
||||
project. But no worries, you've got options!
|
||||
</Text>
|
||||
|
||||
{typeof window !== 'undefined' && window.parent && window.parent !== window && (
|
||||
<View style={styles.createPageContainer}>
|
||||
<View style={styles.createPageContent}>
|
||||
<View style={styles.createPageTextContainer}>
|
||||
<Text style={styles.createPageTitle}>Build it from scratch</Text>
|
||||
<Text style={styles.createPageDescription}>
|
||||
Create a new screen to live at "/{missingPath}"
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.createPageButtonContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleCreatePage()}
|
||||
style={styles.createPageButton}
|
||||
>
|
||||
<Text style={styles.createPageButtonText}>Create Screen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.routesLabel}>Check out all your project's routes here ↓</Text>
|
||||
{!isExpoSitemap && sitemap ? (
|
||||
<View style={styles.pagesContainer}>
|
||||
<View style={styles.pagesListContainer}>
|
||||
<Text style={styles.pagesLabel}>MOBILE</Text>
|
||||
{((sitemap as ParentSitemap).expoPages || []).map((route, _index: number) => (
|
||||
<TouchableOpacity
|
||||
key={route.id}
|
||||
onPress={() => handleNavigate(route.cleanRoute || '')}
|
||||
style={styles.pageButton}
|
||||
>
|
||||
<Text style={styles.routeName}>{route.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.pagesContainer}>
|
||||
<View style={styles.pagesListContainer}>
|
||||
<Text style={styles.pagesLabel}>MOBILE</Text>
|
||||
{(availableRoutes as SitemapType[]).map((route: SitemapType, _index: number) => {
|
||||
const url =
|
||||
typeof route.href === 'string' ? route.href : route.href?.pathname || '/';
|
||||
|
||||
if (url === '/(tabs)' && route.children) {
|
||||
return route.children.map((childRoute: SitemapType) => {
|
||||
const childUrl =
|
||||
typeof childRoute.href === 'string'
|
||||
? childRoute.href
|
||||
: childRoute.href.pathname || '/';
|
||||
const displayPath =
|
||||
childUrl === '/(tabs)'
|
||||
? 'Homepage'
|
||||
: childUrl.replace(/^\//, '').replace(/^\(tabs\)\//, '');
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={childRoute.contextKey}
|
||||
onPress={() => handleNavigate(childUrl)}
|
||||
style={styles.pageButton}
|
||||
>
|
||||
<Text style={styles.routeName}>{displayPath}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const displayPath = url === '/' ? 'Homepage' : url.replace(/^\//, '');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={route.contextKey}
|
||||
onPress={() => handleNavigate(url)}
|
||||
style={styles.pageButton}
|
||||
>
|
||||
<Text style={styles.routeName}>{displayPath}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
gap: 8,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pathContainer: {
|
||||
flexDirection: 'row',
|
||||
height: 32,
|
||||
width: 300,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f9f9f9',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pathPrefix: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 5,
|
||||
justifyContent: 'center',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#e5e5e5',
|
||||
},
|
||||
pathPrefixText: {
|
||||
color: '#666',
|
||||
},
|
||||
pathContent: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pathText: {
|
||||
color: '#666',
|
||||
},
|
||||
mainContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingTop: 40,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '500',
|
||||
color: '#111',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 48,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
boldText: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
routesLabel: {
|
||||
color: '#666',
|
||||
marginBottom: 80,
|
||||
textAlign: 'center',
|
||||
},
|
||||
createPageContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
marginBottom: 40,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
createPageContent: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
gap: 15,
|
||||
},
|
||||
createPageTextContainer: {
|
||||
gap: 10,
|
||||
},
|
||||
createPageTitle: {
|
||||
fontSize: 14,
|
||||
color: '#000',
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
createPageDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
createPageButtonContainer: {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
createPageButton: {
|
||||
backgroundColor: '#000',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
},
|
||||
createPageButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
pagesContainer: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
pagesLabel: {
|
||||
fontSize: 14,
|
||||
color: '#ccc',
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: 10,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
pagesListContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
gap: 10,
|
||||
},
|
||||
pageButton: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
elevation: 1,
|
||||
},
|
||||
routeName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#111',
|
||||
},
|
||||
routePath: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
|
||||
routesContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
gap: 40,
|
||||
},
|
||||
routeCard: {
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
minWidth: 150,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
routeButton: {
|
||||
width: '100%',
|
||||
aspectRatio: 1.4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
routePreview: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9f9f9',
|
||||
},
|
||||
routeLabel: {
|
||||
paddingTop: 12,
|
||||
color: '#666',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<NotFoundScreen />
|
||||
);
|
||||
};
|
||||
71
apps/mobile/src/app/_layout.tsx
Normal file
71
apps/mobile/src/app/_layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* This file is customizable BUT — do not remove:
|
||||
* • `<AuthModal />` render (shipped v2 auth modal; removing it breaks
|
||||
* signin/signup since useAuth().signIn() only flips state, not render)
|
||||
* • `useAuth().initiate()` + `isReady` gate (loads persisted session from
|
||||
* SecureStore — removing causes user to appear signed-out on app launch)
|
||||
*
|
||||
* Safe to change: the Stack routes, QueryClient config, splash behavior, the
|
||||
* wrapping providers, or to add nested providers around <Stack>.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { ErrorBoundary } from '@/__create/ErrorBoundary';
|
||||
import { useAuth } from '@/utils/auth/useAuth';
|
||||
import { AuthModal } from '@/utils/auth/useAuthModal';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
void SplashScreen.preventAutoHideAsync();
|
||||
|
||||
const SPLASH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
const { initiate, isReady } = useAuth();
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initiate();
|
||||
}, [initiate]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setTimedOut(true), SPLASH_TIMEOUT_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady || timedOut) {
|
||||
void SplashScreen.hideAsync();
|
||||
}
|
||||
}, [isReady, timedOut]);
|
||||
|
||||
if (!isReady && !timedOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack screenOptions={{ headerShown: false }} initialRouteName="(tabs)">
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<AuthModal />
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
3
apps/mobile/src/app/index.tsx
Normal file
3
apps/mobile/src/app/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Index() {
|
||||
return null;
|
||||
}
|
||||
147
apps/mobile/src/components/KeyboardAvoidingAnimatedView.tsx
Normal file
147
apps/mobile/src/components/KeyboardAvoidingAnimatedView.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useRef, useEffect, forwardRef } from 'react';
|
||||
import { Platform, Keyboard, KeyboardAvoidingView, LayoutChangeEvent, ViewStyle, KeyboardEvent } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
|
||||
interface Layout {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface KeyboardAvoidingAnimatedViewProps {
|
||||
children: React.ReactNode;
|
||||
behavior?: 'padding' | 'height' | 'position';
|
||||
keyboardVerticalOffset?: number;
|
||||
style?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
enabled?: boolean;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
const KeyboardAvoidingAnimatedView = forwardRef<Animated.View, KeyboardAvoidingAnimatedViewProps>((props, ref) => {
|
||||
const {
|
||||
children,
|
||||
behavior = Platform.OS === 'ios' ? 'padding' : 'height',
|
||||
keyboardVerticalOffset = 0,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
enabled = true,
|
||||
onLayout,
|
||||
...leftoverProps
|
||||
} = props;
|
||||
|
||||
const animatedViewRef = useRef<Layout | null>(null); // ref to animated view in this polyfill
|
||||
const initialHeight = useSharedValue(0); // original height of animated view before keyboard appears
|
||||
const bottomHeight = useSharedValue(0); // whats going to be added to the bottom when keyboard appears
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const onKeyboardShow = (event: KeyboardEvent) => {
|
||||
const { duration, endCoordinates } = event;
|
||||
const animatedView = animatedViewRef.current;
|
||||
|
||||
if (!animatedView) return;
|
||||
|
||||
let height = 0;
|
||||
|
||||
// calculate how much the view needs to move up
|
||||
const keyboardY = endCoordinates.screenY - keyboardVerticalOffset;
|
||||
height = Math.max(animatedView.y + animatedView.height - keyboardY, 0);
|
||||
|
||||
bottomHeight.value = withTiming(height, {
|
||||
duration: duration > 10 ? duration : 300,
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyboardHide = () => {
|
||||
bottomHeight.value = withTiming(0, { duration: 300 });
|
||||
};
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const showSubscription = Keyboard.addListener(showEvent, onKeyboardShow);
|
||||
const hideSubscription = Keyboard.addListener(hideEvent, onKeyboardHide);
|
||||
|
||||
return () => {
|
||||
showSubscription.remove();
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, [keyboardVerticalOffset, enabled, bottomHeight]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
if (behavior === 'height') {
|
||||
return {
|
||||
height: initialHeight.value - bottomHeight.value,
|
||||
flex: bottomHeight.value > 0 ? 0 : (null as never),
|
||||
};
|
||||
}
|
||||
if (behavior === 'padding') {
|
||||
return {
|
||||
paddingBottom: bottomHeight.value,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const positionAnimatedStyle = useAnimatedStyle(() => ({
|
||||
bottom: bottomHeight.value,
|
||||
}));
|
||||
|
||||
const handleLayout = (event: LayoutChangeEvent) => {
|
||||
const layout = event.nativeEvent.layout;
|
||||
animatedViewRef.current = layout;
|
||||
|
||||
// initial height before keybaord appears
|
||||
if (initialHeight.value === 0) {
|
||||
initialHeight.value = layout.height;
|
||||
}
|
||||
|
||||
if (onLayout) {
|
||||
onLayout(event);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (behavior === 'position') {
|
||||
return (
|
||||
<Animated.View style={[contentContainerStyle, positionAnimatedStyle]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
// render children if padding or height
|
||||
return children;
|
||||
};
|
||||
|
||||
// for web, default to unused keyboard avoiding view
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={behavior}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
{...leftoverProps}
|
||||
>
|
||||
{children}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
ref={ref}
|
||||
style={[style, animatedStyle]}
|
||||
onLayout={handleLayout}
|
||||
{...leftoverProps}
|
||||
>
|
||||
{renderContent()}
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
KeyboardAvoidingAnimatedView.displayName = 'KeyboardAvoidingAnimatedView';
|
||||
|
||||
export default KeyboardAvoidingAnimatedView;
|
||||
163
apps/mobile/src/utils/auth/AuthWebView.tsx
Normal file
163
apps/mobile/src/utils/auth/AuthWebView.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 auth WebView. Handles both native (iOS/Android WebView +
|
||||
* onShouldStartLoadWithRequest → fetch /api/auth/token → setAuth) and web
|
||||
* (iframe + window.addEventListener('message') listening for AUTH_SUCCESS
|
||||
* postMessage from /api/auth/expo-web-success). BOTH code paths are
|
||||
* load-bearing; do NOT delete the web branch because you're only testing
|
||||
* native, and vice versa. The postMessage contract { type, jwt, user } must
|
||||
* stay in sync with /api/auth/expo-web-success/route.ts.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
|
||||
import { useAuthStore } from './store';
|
||||
|
||||
const callbackUrl = '/api/auth/token';
|
||||
const callbackQueryString = `callbackUrl=${callbackUrl}`;
|
||||
|
||||
// Normalize the expected origin once. `new URL(...).origin` strips trailing
|
||||
// slashes, paths, and query — so a stray slash in EXPO_PUBLIC_PROXY_BASE_URL
|
||||
// no longer silently drops every postMessage from the auth iframe.
|
||||
const allowedOrigin = (() => {
|
||||
const raw = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
interface AuthWebViewProps {
|
||||
mode: 'signup' | 'signin';
|
||||
proxyURL: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
interface AuthMessageData {
|
||||
type: 'AUTH_SUCCESS' | 'AUTH_ERROR';
|
||||
jwt?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
image: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This renders a WebView for authentication and handles both web and native platforms.
|
||||
*/
|
||||
export const AuthWebView = ({ mode, proxyURL, baseURL }: AuthWebViewProps) => {
|
||||
const [currentURI, setURI] = useState(`${baseURL}/account/${mode}?${callbackQueryString}`);
|
||||
const { auth, setAuth, isReady } = useAuthStore();
|
||||
const isAuthenticated = isReady ? !!auth : null;
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated) {
|
||||
router.back();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
setURI(`${baseURL}/account/${mode}?${callbackQueryString}`);
|
||||
}, [mode, baseURL, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.addEventListener) {
|
||||
return;
|
||||
}
|
||||
const handleMessage = (event: MessageEvent<AuthMessageData>) => {
|
||||
// Verify the origin for security. Compare normalized origins so a
|
||||
// trailing slash or path in EXPO_PUBLIC_PROXY_BASE_URL doesn't drop
|
||||
// legitimate messages. Surface drops via console.warn instead of
|
||||
// silently swallowing them.
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
||||
console.warn(
|
||||
`AuthWebView: dropping message from unexpected origin ${event.origin}; expected ${allowedOrigin}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.data.type === 'AUTH_SUCCESS') {
|
||||
setAuth({
|
||||
jwt: event.data.jwt!,
|
||||
user: event.data.user!,
|
||||
});
|
||||
} else if (event.data.type === 'AUTH_ERROR') {
|
||||
console.error('Auth error:', event.data.error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [setAuth]);
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const handleIframeError = () => {
|
||||
console.error('Failed to load auth iframe');
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Authentication"
|
||||
src={`${proxyURL}/account/${mode}?callbackUrl=/api/auth/expo-web-success`}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
onError={handleIframeError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WebView
|
||||
sharedCookiesEnabled
|
||||
source={{
|
||||
uri: currentURI,
|
||||
}}
|
||||
headers={{
|
||||
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID!,
|
||||
host: process.env.EXPO_PUBLIC_HOST!,
|
||||
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST!,
|
||||
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST!,
|
||||
}}
|
||||
onShouldStartLoadWithRequest={(request: WebViewNavigation) => {
|
||||
if (request.url === `${baseURL}${callbackUrl}`) {
|
||||
fetch(request.url)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setAuth({ jwt: data.jwt, user: data.user });
|
||||
})
|
||||
.catch(() => {});
|
||||
return false;
|
||||
}
|
||||
if (request.url === currentURI) return true;
|
||||
|
||||
// Add query string properly by checking if URL already has parameters
|
||||
const hasParams = request.url.includes('?');
|
||||
const separator = hasParams ? '&' : '?';
|
||||
const newURL = request.url.replaceAll(proxyURL, baseURL);
|
||||
if (newURL.endsWith(callbackUrl)) {
|
||||
setURI(newURL);
|
||||
return false;
|
||||
}
|
||||
setURI(`${newURL}${separator}${callbackQueryString}`);
|
||||
return false;
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
40
apps/mobile/src/utils/auth/getSession.ts
Normal file
40
apps/mobile/src/utils/auth/getSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 auth helpers. `authFetch` auto-adds Authorization: Bearer <jwt>
|
||||
* when a session exists — use it instead of bare fetch() for calls to the
|
||||
* web app's API routes. The web server's better-auth bearer() plugin
|
||||
* validates these headers. DO NOT reimplement these helpers in user code.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from './store';
|
||||
|
||||
/**
|
||||
* Read the current session (jwt + user) synchronously from the auth store.
|
||||
* Returns null if the user is not authenticated.
|
||||
*/
|
||||
export const getSession = () => useAuthStore.getState().auth;
|
||||
|
||||
/**
|
||||
* Read the current session's JWT for use in an Authorization header.
|
||||
* Returns null if the user is not authenticated.
|
||||
*/
|
||||
export const getJwt = () => useAuthStore.getState().auth?.jwt ?? null;
|
||||
|
||||
/**
|
||||
* Drop-in replacement for fetch() that automatically adds the
|
||||
* `Authorization: Bearer <jwt>` header when the user is signed in. Use this
|
||||
* for calls from the mobile app to the web app's API routes — the web server
|
||||
* uses better-auth's `bearer()` plugin to authenticate these requests.
|
||||
*
|
||||
* Existing Authorization headers on the caller's `init.headers` are preserved.
|
||||
*/
|
||||
export const authFetch: typeof fetch = (input, init) => {
|
||||
const jwt = getJwt();
|
||||
const headers = new Headers(init?.headers);
|
||||
if (jwt && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${jwt}`);
|
||||
}
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
14
apps/mobile/src/utils/auth/index.ts
Normal file
14
apps/mobile/src/utils/auth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 auth barrel. Keeps the public import surface stable so user
|
||||
* code can `import { useAuth, useUser, useAuthModal, authFetch } from '@/utils/auth'`.
|
||||
* DO NOT remove or rename these re-exports — downstream code breaks.
|
||||
*/
|
||||
import { useAuth, useRequireAuth } from './useAuth';
|
||||
import { useUser } from './useUser';
|
||||
import { useAuthModal } from './store';
|
||||
|
||||
export { useAuth, useRequireAuth, useUser, useAuthModal };
|
||||
export { authFetch, getJwt, getSession } from './getSession';
|
||||
export default useAuth;
|
||||
95
apps/mobile/src/utils/auth/store.ts
Normal file
95
apps/mobile/src/utils/auth/store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 zustand stores for auth session (persisted to SecureStore) and
|
||||
* auth modal open/close state. `useAuth`, `useAuthModal` component, and
|
||||
* `AuthWebView` all read from these stores — renaming fields or changing
|
||||
* shape breaks all three. DO NOT replace with Context, DO NOT merge into a
|
||||
* single store.
|
||||
*/
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { create } from 'zustand';
|
||||
|
||||
export const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
|
||||
|
||||
/**
|
||||
* Explicit Keychain options used on every SecureStore call in the auth flow.
|
||||
*
|
||||
* - keychainService: pinned to a stable name so reads and writes always hit
|
||||
* the same partition. Without this, SecureStore derives a service name from
|
||||
* the bundle that can drift between Classic and EAS builds, causing reads
|
||||
* to miss writes from a previous build.
|
||||
* - keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY allows the auth
|
||||
* token to be read on every cold launch after the device has been unlocked
|
||||
* once since boot. The default (WHEN_UNLOCKED) refuses access during the
|
||||
* first-unlock window, which is the most common TestFlight failure mode.
|
||||
* - requireAuthentication: false keeps SecureStore on its non-biometric code
|
||||
* path, so it never reads NSFaceIDUsageDescription or constructs a
|
||||
* biometry-current-set access control object — both of which can throw
|
||||
* NSException and trip iOS 26's unhandled async-void TurboModule rethrow.
|
||||
*/
|
||||
export const secureStoreOptions: SecureStore.SecureStoreOptions = {
|
||||
keychainService: 'anything-auth',
|
||||
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
||||
requireAuthentication: false,
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
jwt: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isReady: boolean;
|
||||
auth: Auth | null;
|
||||
setAuth: (auth: Auth | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This store manages the authentication state of the application.
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
isReady: false,
|
||||
auth: null,
|
||||
setAuth: (auth) => {
|
||||
if (auth) {
|
||||
SecureStore.setItemAsync(
|
||||
authKey,
|
||||
JSON.stringify(auth),
|
||||
secureStoreOptions,
|
||||
).catch(() => {
|
||||
// Swallow Keychain write errors — the app remains in-memory authed
|
||||
// for this session and the next launch will re-auth via the WebView.
|
||||
// Throwing here would propagate into the unhandled-rejection /
|
||||
// TurboModule rethrow path and crash on iOS 26.x.
|
||||
});
|
||||
} else {
|
||||
SecureStore.deleteItemAsync(authKey, secureStoreOptions).catch(() => {});
|
||||
}
|
||||
set({ auth });
|
||||
},
|
||||
}));
|
||||
|
||||
interface AuthModalState {
|
||||
isOpen: boolean;
|
||||
mode: 'signup' | 'signin';
|
||||
open: (options?: { mode?: 'signup' | 'signin' }) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This store manages the state of the authentication modal.
|
||||
*/
|
||||
export const useAuthModal = create<AuthModalState>((set) => ({
|
||||
isOpen: false,
|
||||
mode: 'signup',
|
||||
open: (options) => set({ isOpen: true, mode: options?.mode || 'signup' }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}));
|
||||
101
apps/mobile/src/utils/auth/useAuth.ts
Normal file
101
apps/mobile/src/utils/auth/useAuth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 mobile auth hook. `useAuth()` is the public surface for
|
||||
* user apps — `{ signIn, signUp, signOut, auth, isAuthenticated, isReady }`.
|
||||
* These names are documented in the v2 auth prompt; user code imports them
|
||||
* directly. DO NOT rename them, DO NOT remove `initiate()` (it loads the
|
||||
* persisted session from SecureStore), and DO NOT add side effects that run
|
||||
* before isReady flips true.
|
||||
*/
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { authKey, type Auth, secureStoreOptions, useAuthModal, useAuthStore } from './store';
|
||||
|
||||
interface UseAuthReturn {
|
||||
isReady: boolean;
|
||||
isAuthenticated: boolean | null;
|
||||
signIn: () => void;
|
||||
signOut: () => void;
|
||||
signUp: () => void;
|
||||
auth: Auth | null;
|
||||
setAuth: (auth: Auth | null) => void;
|
||||
initiate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook provides authentication functionality.
|
||||
* It may be easier to use the `useAuthModal` or `useRequireAuth` hooks
|
||||
* instead as those will also handle showing authentication to the user
|
||||
* directly.
|
||||
*/
|
||||
export const useAuth = (): UseAuthReturn => {
|
||||
const { isReady, auth, setAuth } = useAuthStore();
|
||||
const { isOpen: _isOpen, close, open } = useAuthModal();
|
||||
|
||||
const initiate = useCallback(() => {
|
||||
// The auth state machine must always reach a terminal state. SecureStore
|
||||
// can throw or hang in TestFlight release builds (Keychain access denied,
|
||||
// missing keychain-access-groups entitlement after EAS migration, locked
|
||||
// device first-unlock state, or iOS 26 TurboModule rethrow). Without a
|
||||
// catch the unhandled rejection leaves isReady=false forever and the
|
||||
// RootLayout renders null — the user sees a blank screen indefinitely.
|
||||
Promise.race<string | null>([
|
||||
SecureStore.getItemAsync(authKey, secureStoreOptions),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 3000)),
|
||||
])
|
||||
.then((authString) => {
|
||||
useAuthStore.setState({
|
||||
auth: authString ? (JSON.parse(authString) as Auth) : null,
|
||||
isReady: true,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
useAuthStore.setState({ auth: null, isReady: true });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, []);
|
||||
|
||||
const signIn = useCallback(() => {
|
||||
open({ mode: 'signin' });
|
||||
}, [open]);
|
||||
const signUp = useCallback(() => {
|
||||
open({ mode: 'signup' });
|
||||
}, [open]);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setAuth(null);
|
||||
close();
|
||||
}, [close, setAuth]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
isAuthenticated: isReady ? !!auth : null,
|
||||
signIn,
|
||||
signOut,
|
||||
signUp,
|
||||
auth,
|
||||
setAuth,
|
||||
initiate,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseRequireAuthOptions {
|
||||
mode?: 'signup' | 'signin';
|
||||
}
|
||||
|
||||
export const useRequireAuth = (options?: UseRequireAuthOptions): UseAuthReturn => {
|
||||
const authReturn = useAuth();
|
||||
const { open } = useAuthModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReturn.isAuthenticated && authReturn.isReady) {
|
||||
open({ mode: options?.mode });
|
||||
}
|
||||
}, [authReturn.isAuthenticated, open, options?.mode, authReturn.isReady]);
|
||||
|
||||
return authReturn;
|
||||
};
|
||||
|
||||
export default useAuth;
|
||||
104
apps/mobile/src/utils/auth/useAuthModal.tsx
Normal file
104
apps/mobile/src/utils/auth/useAuthModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* Shipped v2 <AuthModal /> — the modal that wraps the AuthWebView. It's
|
||||
* already mounted in app/_layout.tsx; DO NOT mount it again. The env-var
|
||||
* preflight (returns a "not configured" modal when EXPO_PUBLIC_BASE_URL or
|
||||
* EXPO_PUBLIC_PROXY_BASE_URL is missing) is intentional — removing it turns
|
||||
* env-var misconfig into a silent "nothing happens" bug. The named export of
|
||||
* useAuthModal at the top is also load-bearing (user code imports it from
|
||||
* this file, not just from ./store).
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Text, View } from 'react-native';
|
||||
import { AuthWebView } from './AuthWebView';
|
||||
import { useAuthModal, useAuthStore } from './store';
|
||||
|
||||
export { useAuthModal } from './store';
|
||||
|
||||
/**
|
||||
* This component renders a modal for authentication purposes.
|
||||
* To show it programmatically, you should either use the `useRequireAuth` hook or the `useAuthModal` hook.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* import { useAuthModal } from '@/utils/useAuthModal';
|
||||
* function MyComponent() {
|
||||
* const { open } = useAuthModal();
|
||||
* return <Button title="Login" onPress={() => open({ mode: 'signin' })} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* import { useRequireAuth } from '@/utils/auth';
|
||||
* function MyComponent() {
|
||||
* // automatically opens the auth modal if the user is not authenticated
|
||||
* useRequireAuth();
|
||||
* return <Text>Protected Content</Text>;
|
||||
* }
|
||||
*
|
||||
*/
|
||||
export const AuthModal = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const { isOpen, mode } = useAuthModal();
|
||||
|
||||
const proxyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
|
||||
const baseURL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
if (!proxyURL || !baseURL) {
|
||||
const missing = [
|
||||
!proxyURL && 'EXPO_PUBLIC_PROXY_BASE_URL',
|
||||
!baseURL && 'EXPO_PUBLIC_BASE_URL',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
console.error(
|
||||
`AuthModal: missing required env var(s): ${missing}. Auth cannot open.`
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
visible={isOpen && !auth}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-white p-[24px]">
|
||||
<Text className="mb-[8px] text-[18px] font-semibold">
|
||||
Auth is not configured
|
||||
</Text>
|
||||
<Text className="text-center text-[14px] text-gray-600">
|
||||
Missing environment variable{missing.includes(',') ? 's' : ''}:{' '}
|
||||
{missing}. Set {missing.includes(',') ? 'them' : 'it'} in your .env
|
||||
and restart the app.
|
||||
</Text>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={isOpen && !auth} animationType="slide" presentationStyle='pageSheet'>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: '#fff',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<AuthWebView
|
||||
mode={mode}
|
||||
proxyURL={proxyURL}
|
||||
baseURL={baseURL}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default useAuthModal;
|
||||
24
apps/mobile/src/utils/auth/useUser.ts
Normal file
24
apps/mobile/src/utils/auth/useUser.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
|
||||
*
|
||||
* V1-compatible mobile user hook. Migrated apps commonly import
|
||||
* `@/utils/auth/useUser` and expect `{ user, data, loading, refetch }`.
|
||||
* Keep this surface stable; the V2 auth state still comes from `useAuth()`.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export const useUser = () => {
|
||||
const { auth, isReady } = useAuth();
|
||||
const user = auth?.user ?? null;
|
||||
const refetch = useCallback(async () => user, [user]);
|
||||
|
||||
return {
|
||||
user,
|
||||
data: user,
|
||||
loading: !isReady,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUser;
|
||||
@@ -0,0 +1,17 @@
|
||||
export const mockConfigure = jest.fn();
|
||||
export const mockSetLogLevel = jest.fn();
|
||||
export const mockGetOfferings = jest.fn();
|
||||
export const mockPurchasePackage = jest.fn();
|
||||
export const mockRestorePurchases = jest.fn();
|
||||
|
||||
const Purchases = {
|
||||
configure: (...args: any[]) => mockConfigure(...args),
|
||||
setLogLevel: (...args: any[]) => mockSetLogLevel(...args),
|
||||
getOfferings: (...args: any[]) => mockGetOfferings(...args),
|
||||
purchasePackage: (...args: any[]) => mockPurchasePackage(...args),
|
||||
restorePurchases: (...args: any[]) => mockRestorePurchases(...args),
|
||||
};
|
||||
|
||||
export default Purchases;
|
||||
export const LOG_LEVEL = { INFO: 'INFO' };
|
||||
export const PRODUCT_CATEGORY = { SUBSCRIPTION: 'SUBSCRIPTION' };
|
||||
@@ -0,0 +1,4 @@
|
||||
export const Platform = {
|
||||
select: (opts: Record<string, any>) => opts.ios,
|
||||
OS: 'ios',
|
||||
};
|
||||
382
apps/mobile/src/utils/iap/__tests__/useInAppPurchase.test.ts
Normal file
382
apps/mobile/src/utils/iap/__tests__/useInAppPurchase.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Tests for the useInAppPurchase logic functions.
|
||||
*
|
||||
* These verify:
|
||||
* 1. Original behavior from the inlined documentation.ts code is preserved
|
||||
* (SDK configuration, offerings loading, subscription status, purchasing)
|
||||
* 2. Bug fixes over the old inline code:
|
||||
* - Offerings are awaited before isReady is set (was fire-and-forget)
|
||||
* - Retry logic handles TestFlight cold-start failures
|
||||
* - getAvailablePackages returns [] instead of throwing on null offerings
|
||||
* - Purchases.configure() is only called once
|
||||
* - restorePurchases is included (App Store Guideline 3.1.1)
|
||||
*/
|
||||
|
||||
import {
|
||||
mockConfigure,
|
||||
mockSetLogLevel,
|
||||
mockGetOfferings,
|
||||
mockPurchasePackage,
|
||||
mockRestorePurchases,
|
||||
} from './__mocks__/react-native-purchases';
|
||||
import {
|
||||
getRevenueCatAPIKey,
|
||||
loadOfferings,
|
||||
fetchSubscriptionStatus,
|
||||
initiatePurchases,
|
||||
getAvailablePackagesFromOfferings,
|
||||
getSubscriptionsFromOfferings,
|
||||
executePurchase,
|
||||
executeRestore,
|
||||
} from '../useInAppPurchase';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const makeOfferings = (hasCurrent = true) => ({
|
||||
current: hasCurrent
|
||||
? {
|
||||
availablePackages: [
|
||||
{
|
||||
identifier: 'lifetime',
|
||||
product: {
|
||||
priceString: '$1.99',
|
||||
productCategory: 'SUBSCRIPTION',
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'credits',
|
||||
product: {
|
||||
priceString: '$4.99',
|
||||
productCategory: 'NON_SUBSCRIPTION',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
function makeStoreCallbacks() {
|
||||
return {
|
||||
setOfferings: jest.fn(),
|
||||
setIsSubscribed: jest.fn(),
|
||||
setIsReady: jest.fn(),
|
||||
isConfigured: { current: false },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = 'PRODUCTION';
|
||||
process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY = 'pk_ios_test';
|
||||
process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY = 'pk_android_test';
|
||||
process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY = 'pk_test_test';
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ hasAccess: false }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('getRevenueCatAPIKey', () => {
|
||||
test('returns iOS key in production', () => {
|
||||
expect(getRevenueCatAPIKey()).toBe('pk_ios_test');
|
||||
});
|
||||
|
||||
test('returns test store key in DEVELOPMENT', () => {
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = 'DEVELOPMENT';
|
||||
expect(getRevenueCatAPIKey()).toBe('pk_test_test');
|
||||
});
|
||||
|
||||
test('returns undefined when no keys are set', () => {
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
||||
expect(getRevenueCatAPIKey()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiatePurchases', () => {
|
||||
test('configures SDK with correct API key', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ apiKey: 'pk_ios_test' });
|
||||
});
|
||||
|
||||
test('sets log level to INFO', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
expect(mockSetLogLevel).toHaveBeenCalledWith('INFO');
|
||||
});
|
||||
|
||||
test('loads offerings and fetches subscription status in parallel', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
expect(cbs.setOfferings).toHaveBeenCalledWith(makeOfferings());
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/revenue-cat/get-subscription-status',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
});
|
||||
|
||||
test('sets isReady true after completion', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('does not configure when no API key is available', async () => {
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
|
||||
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
expect(mockConfigure).not.toHaveBeenCalled();
|
||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('BUG FIX: isReady only set AFTER offerings have loaded (was fire-and-forget)', async () => {
|
||||
let resolveOfferings!: Function;
|
||||
mockGetOfferings.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveOfferings = () => resolve(makeOfferings()); })
|
||||
);
|
||||
const cbs = makeStoreCallbacks();
|
||||
const promise = initiatePurchases(cbs);
|
||||
|
||||
// Before offerings resolve, setIsReady should NOT have been called
|
||||
expect(cbs.setIsReady).not.toHaveBeenCalled();
|
||||
|
||||
resolveOfferings();
|
||||
await promise;
|
||||
|
||||
// Now it should be called
|
||||
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
|
||||
expect(cbs.setOfferings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('BUG FIX: configure() only called once even if initiate() called multiple times', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings());
|
||||
const cbs = makeStoreCallbacks();
|
||||
await initiatePurchases(cbs);
|
||||
await initiatePurchases(cbs);
|
||||
await initiatePurchases(cbs);
|
||||
expect(mockConfigure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadOfferings', () => {
|
||||
test('stores offerings on success', async () => {
|
||||
const offerings = makeOfferings();
|
||||
mockGetOfferings.mockResolvedValue(offerings);
|
||||
const setOfferings = jest.fn();
|
||||
await loadOfferings(setOfferings);
|
||||
expect(setOfferings).toHaveBeenCalledWith(offerings);
|
||||
});
|
||||
|
||||
test('BUG FIX: retries up to 3 times on failure', async () => {
|
||||
mockGetOfferings
|
||||
.mockRejectedValueOnce(new Error('cold start'))
|
||||
.mockRejectedValueOnce(new Error('still loading'))
|
||||
.mockResolvedValueOnce(makeOfferings());
|
||||
const setOfferings = jest.fn();
|
||||
|
||||
const promise = loadOfferings(setOfferings);
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
await promise;
|
||||
|
||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
||||
expect(setOfferings).toHaveBeenCalledWith(makeOfferings());
|
||||
});
|
||||
|
||||
test('BUG FIX: retries when offerings load but current is null', async () => {
|
||||
mockGetOfferings
|
||||
.mockResolvedValueOnce(makeOfferings(false))
|
||||
.mockResolvedValueOnce(makeOfferings(false))
|
||||
.mockResolvedValueOnce(makeOfferings(true));
|
||||
const setOfferings = jest.fn();
|
||||
|
||||
const promise = loadOfferings(setOfferings);
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
await promise;
|
||||
|
||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
||||
expect(setOfferings).toHaveBeenCalledWith(makeOfferings(true));
|
||||
});
|
||||
|
||||
test('BUG FIX: does not call setOfferings when all retries fail', async () => {
|
||||
mockGetOfferings.mockRejectedValue(new Error('permanent failure'));
|
||||
const setOfferings = jest.fn();
|
||||
|
||||
const promise = loadOfferings(setOfferings);
|
||||
await jest.advanceTimersByTimeAsync(3000);
|
||||
await promise;
|
||||
|
||||
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
|
||||
expect(setOfferings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('stops retrying early when current offering is found', async () => {
|
||||
mockGetOfferings.mockResolvedValue(makeOfferings(true));
|
||||
const setOfferings = jest.fn();
|
||||
|
||||
await loadOfferings(setOfferings);
|
||||
|
||||
expect(mockGetOfferings).toHaveBeenCalledTimes(1);
|
||||
expect(setOfferings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSubscriptionStatus', () => {
|
||||
test('sets subscribed true when server returns hasAccess true', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ hasAccess: true }),
|
||||
});
|
||||
const setIsSubscribed = jest.fn();
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
expect(setIsSubscribed).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('sets subscribed false when server returns hasAccess false', async () => {
|
||||
const setIsSubscribed = jest.fn();
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('sets subscribed false on network error', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('network'));
|
||||
const setIsSubscribed = jest.fn();
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('sets subscribed false on non-ok response', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({ ok: false });
|
||||
const setIsSubscribed = jest.fn();
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
expect(setIsSubscribed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailablePackagesFromOfferings', () => {
|
||||
test('returns packages from current offering', () => {
|
||||
const offerings = makeOfferings();
|
||||
const packages = getAvailablePackagesFromOfferings(offerings);
|
||||
expect(packages).toHaveLength(2);
|
||||
expect(packages[0].identifier).toBe('lifetime');
|
||||
});
|
||||
|
||||
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
|
||||
expect(() => getAvailablePackagesFromOfferings(null)).not.toThrow();
|
||||
expect(getAvailablePackagesFromOfferings(null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('BUG FIX: returns [] when current is null (old code threw)', () => {
|
||||
expect(() => getAvailablePackagesFromOfferings(makeOfferings(false))).not.toThrow();
|
||||
expect(getAvailablePackagesFromOfferings(makeOfferings(false))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptionsFromOfferings', () => {
|
||||
test('filters by SUBSCRIPTION category', () => {
|
||||
const subs = getSubscriptionsFromOfferings(makeOfferings());
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(subs[0].identifier).toBe('lifetime');
|
||||
});
|
||||
|
||||
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
|
||||
expect(() => getSubscriptionsFromOfferings(null)).not.toThrow();
|
||||
expect(getSubscriptionsFromOfferings(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executePurchase', () => {
|
||||
test('calls SDK and returns success with customerInfo', async () => {
|
||||
const customerInfo = { entitlements: { active: { pro: {} } } };
|
||||
mockPurchasePackage.mockResolvedValue({ customerInfo });
|
||||
const result = await executePurchase({
|
||||
pkg: { identifier: 'test' },
|
||||
setIsSubscribed: jest.fn(),
|
||||
});
|
||||
expect(mockPurchasePackage).toHaveBeenCalledWith({ identifier: 'test' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.customerInfo).toBe(customerInfo);
|
||||
});
|
||||
|
||||
test('returns cancelled when user cancels', async () => {
|
||||
mockPurchasePackage.mockRejectedValue({ userCancelled: true });
|
||||
const result = await executePurchase({
|
||||
pkg: { identifier: 'test' },
|
||||
setIsSubscribed: jest.fn(),
|
||||
});
|
||||
expect(result).toEqual({ success: false, cancelled: true });
|
||||
});
|
||||
|
||||
test('returns failure on error', async () => {
|
||||
mockPurchasePackage.mockRejectedValue(new Error('payment failed'));
|
||||
const result = await executePurchase({
|
||||
pkg: { identifier: 'test' },
|
||||
setIsSubscribed: jest.fn(),
|
||||
});
|
||||
expect(result).toEqual({ success: false, cancelled: false });
|
||||
});
|
||||
|
||||
test('refreshes subscription status after purchase', async () => {
|
||||
mockPurchasePackage.mockResolvedValue({
|
||||
customerInfo: { entitlements: { active: {} } },
|
||||
});
|
||||
const setIsSubscribed = jest.fn();
|
||||
await executePurchase({ pkg: { identifier: 'test' }, setIsSubscribed });
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/revenue-cat/get-subscription-status',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeRestore', () => {
|
||||
test('BUG FIX: restorePurchases works (App Store Guideline 3.1.1)', async () => {
|
||||
const customerInfo = { entitlements: { active: { premium: {} } } };
|
||||
mockRestorePurchases.mockResolvedValue(customerInfo);
|
||||
const result = await executeRestore(jest.fn());
|
||||
expect(mockRestorePurchases).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.customerInfo).toBe(customerInfo);
|
||||
});
|
||||
|
||||
test('returns success false when no active entitlements', async () => {
|
||||
mockRestorePurchases.mockResolvedValue({ entitlements: { active: {} } });
|
||||
const result = await executeRestore(jest.fn());
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('handles errors gracefully', async () => {
|
||||
mockRestorePurchases.mockRejectedValue(new Error('network error'));
|
||||
const result = await executeRestore(jest.fn());
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
test('refreshes subscription status after restore', async () => {
|
||||
mockRestorePurchases.mockResolvedValue({
|
||||
entitlements: { active: { pro: {} } },
|
||||
});
|
||||
await executeRestore(jest.fn());
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/revenue-cat/get-subscription-status',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
});
|
||||
});
|
||||
2
apps/mobile/src/utils/iap/index.ts
Normal file
2
apps/mobile/src/utils/iap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useInAppPurchase } from './useInAppPurchase';
|
||||
export { useInAppPurchaseStore } from './store';
|
||||
19
apps/mobile/src/utils/iap/store.ts
Normal file
19
apps/mobile/src/utils/iap/store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface InAppPurchaseState {
|
||||
isReady: boolean;
|
||||
offerings: any | null;
|
||||
isSubscribed: boolean;
|
||||
setIsSubscribed: (isSubscribed: boolean) => void;
|
||||
setOfferings: (offerings: any | null) => void;
|
||||
setIsReady: (isReady: boolean) => void;
|
||||
}
|
||||
|
||||
export const useInAppPurchaseStore = create<InAppPurchaseState>((set) => ({
|
||||
isReady: false,
|
||||
offerings: null,
|
||||
isSubscribed: false,
|
||||
setIsSubscribed: (isSubscribed) => set({ isSubscribed }),
|
||||
setOfferings: (offerings) => set({ offerings }),
|
||||
setIsReady: (isReady) => set({ isReady }),
|
||||
}));
|
||||
211
apps/mobile/src/utils/iap/useInAppPurchase.ts
Normal file
211
apps/mobile/src/utils/iap/useInAppPurchase.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import Purchases, { LOG_LEVEL, PRODUCT_CATEGORY } from 'react-native-purchases';
|
||||
import { Platform } from 'react-native';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useInAppPurchaseStore } from './store';
|
||||
|
||||
export const RETRY_ATTEMPTS = 3;
|
||||
export const RETRY_DELAY_MS = 1500;
|
||||
|
||||
export const getRevenueCatAPIKey = (): string | undefined => {
|
||||
if (process.env.EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT') {
|
||||
return process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
|
||||
}
|
||||
return Platform.select({
|
||||
ios: process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY,
|
||||
android: process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY,
|
||||
web: process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY,
|
||||
});
|
||||
};
|
||||
|
||||
export async function loadOfferings(setOfferings: (o: any) => void) {
|
||||
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const result = await Purchases.getOfferings();
|
||||
if (result?.current) {
|
||||
setOfferings(result);
|
||||
return;
|
||||
}
|
||||
console.warn(
|
||||
`RevenueCat offerings loaded but no current offering (attempt ${attempt + 1}/${RETRY_ATTEMPTS})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to load offerings (attempt ${attempt + 1}/${RETRY_ATTEMPTS}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (attempt < RETRY_ATTEMPTS - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionStatus(
|
||||
setIsSubscribed: (v: boolean) => void
|
||||
) {
|
||||
try {
|
||||
const response = await fetch('/api/revenue-cat/get-subscription-status', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check subscription status');
|
||||
}
|
||||
const data = await response.json();
|
||||
setIsSubscribed(data.hasAccess);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscription status:', error);
|
||||
setIsSubscribed(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initiatePurchases({
|
||||
isConfigured,
|
||||
setIsReady,
|
||||
setOfferings,
|
||||
setIsSubscribed,
|
||||
}: {
|
||||
isConfigured: { current: boolean };
|
||||
setIsReady: (v: boolean) => void;
|
||||
setOfferings: (o: any) => void;
|
||||
setIsSubscribed: (v: boolean) => void;
|
||||
}) {
|
||||
if (isConfigured.current) return;
|
||||
try {
|
||||
void Purchases.setLogLevel(LOG_LEVEL.INFO);
|
||||
const apiKey = getRevenueCatAPIKey();
|
||||
if (apiKey) {
|
||||
Purchases.configure({ apiKey });
|
||||
isConfigured.current = true;
|
||||
await Promise.allSettled([
|
||||
loadOfferings(setOfferings),
|
||||
fetchSubscriptionStatus(setIsSubscribed),
|
||||
]);
|
||||
} else {
|
||||
console.warn('No RevenueCat API key found for platform:', Platform.OS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize RevenueCat:', error);
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailablePackagesFromOfferings(offerings: any) {
|
||||
const offering = offerings?.current;
|
||||
if (!offering) {
|
||||
return [];
|
||||
}
|
||||
return offering.availablePackages;
|
||||
}
|
||||
|
||||
export function getSubscriptionsFromOfferings(offerings: any) {
|
||||
return getAvailablePackagesFromOfferings(offerings).filter(
|
||||
(pkg: any) =>
|
||||
pkg.product.productCategory === PRODUCT_CATEGORY.SUBSCRIPTION
|
||||
);
|
||||
}
|
||||
|
||||
export async function executePurchase({
|
||||
pkg,
|
||||
setIsSubscribed,
|
||||
}: {
|
||||
pkg: any;
|
||||
setIsSubscribed: (v: boolean) => void;
|
||||
}) {
|
||||
try {
|
||||
const { customerInfo } = await Purchases.purchasePackage(pkg);
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
return { success: true, customerInfo };
|
||||
} catch (error: any) {
|
||||
if (error.userCancelled) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
console.error('Failed to purchase:', error);
|
||||
return { success: false, cancelled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRestore(
|
||||
setIsSubscribed: (v: boolean) => void
|
||||
) {
|
||||
try {
|
||||
const customerInfo = await Purchases.restorePurchases();
|
||||
await fetchSubscriptionStatus(setIsSubscribed);
|
||||
return {
|
||||
success: Object.keys(customerInfo.entitlements.active).length > 0,
|
||||
customerInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to restore purchases:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function useInAppPurchase() {
|
||||
const {
|
||||
isReady,
|
||||
offerings,
|
||||
setOfferings,
|
||||
setIsSubscribed,
|
||||
isSubscribed,
|
||||
setIsReady,
|
||||
} = useInAppPurchaseStore();
|
||||
const [isPurchasing, setIsPurchasing] = useState(false);
|
||||
const isConfigured = useRef(false);
|
||||
|
||||
const initiate = useCallback(
|
||||
() =>
|
||||
initiatePurchases({
|
||||
isConfigured,
|
||||
setIsReady,
|
||||
setOfferings,
|
||||
setIsSubscribed,
|
||||
}),
|
||||
[setIsReady, setOfferings, setIsSubscribed]
|
||||
);
|
||||
|
||||
const getAvailablePackages = useCallback(
|
||||
() => getAvailablePackagesFromOfferings(offerings),
|
||||
[offerings]
|
||||
);
|
||||
|
||||
const getAvailableSubscriptions = useCallback(
|
||||
() => getSubscriptionsFromOfferings(offerings),
|
||||
[offerings]
|
||||
);
|
||||
|
||||
const purchasePackage = useCallback(
|
||||
async ({ pkg }: { pkg: any }) => {
|
||||
setIsPurchasing(true);
|
||||
try {
|
||||
return await executePurchase({ pkg, setIsSubscribed });
|
||||
} finally {
|
||||
setIsPurchasing(false);
|
||||
}
|
||||
},
|
||||
[setIsPurchasing, setIsSubscribed]
|
||||
);
|
||||
|
||||
const restorePurchases = useCallback(async () => {
|
||||
setIsPurchasing(true);
|
||||
try {
|
||||
return await executeRestore(setIsSubscribed);
|
||||
} finally {
|
||||
setIsPurchasing(false);
|
||||
}
|
||||
}, [setIsPurchasing, setIsSubscribed]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
offerings,
|
||||
isSubscribed,
|
||||
isPurchasing,
|
||||
initiate,
|
||||
getAvailablePackages,
|
||||
getAvailableSubscriptions,
|
||||
purchasePackage,
|
||||
restorePurchases,
|
||||
};
|
||||
}
|
||||
|
||||
export default useInAppPurchase;
|
||||
34
apps/mobile/src/utils/useHandleStreamResponse.ts
Normal file
34
apps/mobile/src/utils/useHandleStreamResponse.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
35
apps/mobile/src/utils/usePreventBack.ts
Normal file
35
apps/mobile/src/utils/usePreventBack.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
149
apps/mobile/src/utils/useUpload.ts
Normal file
149
apps/mobile/src/utils/useUpload.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user