@croacroa/react-native-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { View, Text, Pressable, KeyboardAvoidingView, Platform, ScrollView } from "react-native";
|
|
2
|
+
import { Link, router } from "expo-router";
|
|
3
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
4
|
+
import { useForm } from "react-hook-form";
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
6
|
+
import Animated, { FadeInDown } from "react-native-reanimated";
|
|
7
|
+
|
|
8
|
+
import { FormInput } from "@/components/forms/FormInput";
|
|
9
|
+
import { AnimatedButton } from "@/components/ui/AnimatedButton";
|
|
10
|
+
import { useAuth } from "@/hooks/useAuth";
|
|
11
|
+
import { registerSchema, RegisterFormData } from "@/utils/validation";
|
|
12
|
+
|
|
13
|
+
export default function RegisterScreen() {
|
|
14
|
+
const { signUp } = useAuth();
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
control,
|
|
18
|
+
handleSubmit,
|
|
19
|
+
formState: { isSubmitting },
|
|
20
|
+
} = useForm<RegisterFormData>({
|
|
21
|
+
resolver: zodResolver(registerSchema),
|
|
22
|
+
defaultValues: {
|
|
23
|
+
name: "",
|
|
24
|
+
email: "",
|
|
25
|
+
password: "",
|
|
26
|
+
confirmPassword: "",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const onSubmit = async (data: RegisterFormData) => {
|
|
31
|
+
try {
|
|
32
|
+
await signUp(data.email, data.password, data.name);
|
|
33
|
+
router.replace("/(auth)/home");
|
|
34
|
+
} catch {
|
|
35
|
+
// Error is handled by useAuth with toast
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<SafeAreaView className="flex-1 bg-background-light dark:bg-background-dark">
|
|
41
|
+
<KeyboardAvoidingView
|
|
42
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
43
|
+
className="flex-1"
|
|
44
|
+
>
|
|
45
|
+
<ScrollView
|
|
46
|
+
contentContainerStyle={{ flexGrow: 1, justifyContent: "center" }}
|
|
47
|
+
keyboardShouldPersistTaps="handled"
|
|
48
|
+
>
|
|
49
|
+
<View className="flex-1 justify-center px-6 py-8">
|
|
50
|
+
{/* Header */}
|
|
51
|
+
<Animated.View
|
|
52
|
+
entering={FadeInDown.delay(100).springify()}
|
|
53
|
+
className="mb-8"
|
|
54
|
+
>
|
|
55
|
+
<Text className="text-3xl font-bold text-text-light dark:text-text-dark">
|
|
56
|
+
Create account
|
|
57
|
+
</Text>
|
|
58
|
+
<Text className="mt-2 text-muted-light dark:text-muted-dark">
|
|
59
|
+
Sign up to get started
|
|
60
|
+
</Text>
|
|
61
|
+
</Animated.View>
|
|
62
|
+
|
|
63
|
+
{/* Form */}
|
|
64
|
+
<Animated.View
|
|
65
|
+
entering={FadeInDown.delay(200).springify()}
|
|
66
|
+
className="gap-4"
|
|
67
|
+
>
|
|
68
|
+
<FormInput
|
|
69
|
+
name="name"
|
|
70
|
+
control={control}
|
|
71
|
+
label="Name"
|
|
72
|
+
placeholder="Enter your name"
|
|
73
|
+
autoComplete="name"
|
|
74
|
+
leftIcon="person-outline"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<FormInput
|
|
78
|
+
name="email"
|
|
79
|
+
control={control}
|
|
80
|
+
label="Email"
|
|
81
|
+
placeholder="Enter your email"
|
|
82
|
+
keyboardType="email-address"
|
|
83
|
+
autoCapitalize="none"
|
|
84
|
+
autoComplete="email"
|
|
85
|
+
leftIcon="mail-outline"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<FormInput
|
|
89
|
+
name="password"
|
|
90
|
+
control={control}
|
|
91
|
+
label="Password"
|
|
92
|
+
placeholder="Create a password"
|
|
93
|
+
secureTextEntry
|
|
94
|
+
autoComplete="new-password"
|
|
95
|
+
leftIcon="lock-closed-outline"
|
|
96
|
+
hint="Min 8 chars, 1 uppercase, 1 lowercase, 1 number"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<FormInput
|
|
100
|
+
name="confirmPassword"
|
|
101
|
+
control={control}
|
|
102
|
+
label="Confirm Password"
|
|
103
|
+
placeholder="Confirm your password"
|
|
104
|
+
secureTextEntry
|
|
105
|
+
autoComplete="new-password"
|
|
106
|
+
leftIcon="lock-closed-outline"
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<AnimatedButton
|
|
110
|
+
onPress={handleSubmit(onSubmit)}
|
|
111
|
+
isLoading={isSubmitting}
|
|
112
|
+
className="mt-4"
|
|
113
|
+
>
|
|
114
|
+
Create Account
|
|
115
|
+
</AnimatedButton>
|
|
116
|
+
</Animated.View>
|
|
117
|
+
|
|
118
|
+
{/* Footer */}
|
|
119
|
+
<Animated.View
|
|
120
|
+
entering={FadeInDown.delay(300).springify()}
|
|
121
|
+
className="mt-8 flex-row justify-center"
|
|
122
|
+
>
|
|
123
|
+
<Text className="text-muted-light dark:text-muted-dark">
|
|
124
|
+
Already have an account?{" "}
|
|
125
|
+
</Text>
|
|
126
|
+
<Link href="/(public)/login" asChild>
|
|
127
|
+
<Pressable>
|
|
128
|
+
<Text className="font-semibold text-primary-600 dark:text-primary-400">
|
|
129
|
+
Sign In
|
|
130
|
+
</Text>
|
|
131
|
+
</Pressable>
|
|
132
|
+
</Link>
|
|
133
|
+
</Animated.View>
|
|
134
|
+
</View>
|
|
135
|
+
</ScrollView>
|
|
136
|
+
</KeyboardAvoidingView>
|
|
137
|
+
</SafeAreaView>
|
|
138
|
+
);
|
|
139
|
+
}
|
package/app/_layout.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import "../global.css";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { Stack } from "expo-router";
|
|
4
|
+
import { StatusBar } from "expo-status-bar";
|
|
5
|
+
import * as SplashScreen from "expo-splash-screen";
|
|
6
|
+
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
7
|
+
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
8
|
+
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
9
|
+
|
|
10
|
+
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
|
11
|
+
import { AuthProvider } from "@/hooks/useAuth";
|
|
12
|
+
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
|
13
|
+
import { useNotifications } from "@/hooks/useNotifications";
|
|
14
|
+
import { useOffline } from "@/hooks/useOffline";
|
|
15
|
+
import {
|
|
16
|
+
queryClient,
|
|
17
|
+
persistOptions,
|
|
18
|
+
setupOnlineManager,
|
|
19
|
+
setupFocusManager,
|
|
20
|
+
} from "@/services/queryClient";
|
|
21
|
+
import { initSentry } from "@/services/sentry";
|
|
22
|
+
|
|
23
|
+
// Initialize Sentry as early as possible
|
|
24
|
+
initSentry();
|
|
25
|
+
|
|
26
|
+
// Prevent splash screen from auto-hiding
|
|
27
|
+
SplashScreen.preventAutoHideAsync();
|
|
28
|
+
|
|
29
|
+
// Setup TanStack Query online/offline management
|
|
30
|
+
setupOnlineManager();
|
|
31
|
+
|
|
32
|
+
function RootLayoutContent() {
|
|
33
|
+
const { isDark, isLoaded } = useTheme();
|
|
34
|
+
const { registerForPushNotifications } = useNotifications();
|
|
35
|
+
|
|
36
|
+
// Track offline status with toast notifications
|
|
37
|
+
useOffline({ showToast: true });
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (isLoaded) {
|
|
41
|
+
SplashScreen.hideAsync();
|
|
42
|
+
}
|
|
43
|
+
}, [isLoaded]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
registerForPushNotifications();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// Setup focus manager (refetch on app focus)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const cleanup = setupFocusManager();
|
|
52
|
+
return cleanup;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
if (!isLoaded) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Stack
|
|
62
|
+
screenOptions={{
|
|
63
|
+
headerShown: false,
|
|
64
|
+
contentStyle: {
|
|
65
|
+
backgroundColor: isDark ? "#0f172a" : "#ffffff",
|
|
66
|
+
},
|
|
67
|
+
animation: "slide_from_right",
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<Stack.Screen name="(public)" />
|
|
71
|
+
<Stack.Screen name="(auth)" />
|
|
72
|
+
</Stack>
|
|
73
|
+
<StatusBar style={isDark ? "light" : "dark"} />
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function RootLayout() {
|
|
79
|
+
return (
|
|
80
|
+
<ErrorBoundary>
|
|
81
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
82
|
+
<SafeAreaProvider>
|
|
83
|
+
<PersistQueryClientProvider
|
|
84
|
+
client={queryClient}
|
|
85
|
+
persistOptions={persistOptions}
|
|
86
|
+
>
|
|
87
|
+
<ThemeProvider>
|
|
88
|
+
<AuthProvider>
|
|
89
|
+
<RootLayoutContent />
|
|
90
|
+
</AuthProvider>
|
|
91
|
+
</ThemeProvider>
|
|
92
|
+
</PersistQueryClientProvider>
|
|
93
|
+
</SafeAreaProvider>
|
|
94
|
+
</GestureHandlerRootView>
|
|
95
|
+
</ErrorBoundary>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/app/index.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Redirect } from "expo-router";
|
|
2
|
+
import { useAuth } from "@/hooks/useAuth";
|
|
3
|
+
import { View, ActivityIndicator } from "react-native";
|
|
4
|
+
|
|
5
|
+
export default function Index() {
|
|
6
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
7
|
+
|
|
8
|
+
if (isLoading) {
|
|
9
|
+
return (
|
|
10
|
+
<View className="flex-1 items-center justify-center bg-background-light dark:bg-background-dark">
|
|
11
|
+
<ActivityIndicator size="large" color="#3b82f6" />
|
|
12
|
+
</View>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (isAuthenticated) {
|
|
17
|
+
return <Redirect href="/(auth)/home" />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return <Redirect href="/(public)/login" />;
|
|
21
|
+
}
|
package/app.config.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ExpoConfig, ConfigContext } from "expo/config";
|
|
2
|
+
|
|
3
|
+
const IS_DEV = process.env.APP_VARIANT === "development";
|
|
4
|
+
const IS_PREVIEW = process.env.APP_VARIANT === "preview";
|
|
5
|
+
|
|
6
|
+
const getUniqueIdentifier = () => {
|
|
7
|
+
if (IS_DEV) return "com.yourcompany.yourapp.dev";
|
|
8
|
+
if (IS_PREVIEW) return "com.yourcompany.yourapp.preview";
|
|
9
|
+
return "com.yourcompany.yourapp";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getAppName = () => {
|
|
13
|
+
if (IS_DEV) return "YourApp (Dev)";
|
|
14
|
+
if (IS_PREVIEW) return "YourApp (Preview)";
|
|
15
|
+
return "YourApp";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default ({ config }: ConfigContext): ExpoConfig => ({
|
|
19
|
+
...config,
|
|
20
|
+
name: getAppName(),
|
|
21
|
+
slug: "your-app",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
orientation: "portrait",
|
|
24
|
+
icon: "./assets/images/icon.png",
|
|
25
|
+
scheme: "yourapp",
|
|
26
|
+
userInterfaceStyle: "automatic",
|
|
27
|
+
splash: {
|
|
28
|
+
image: "./assets/images/splash.png",
|
|
29
|
+
resizeMode: "contain",
|
|
30
|
+
backgroundColor: "#ffffff",
|
|
31
|
+
},
|
|
32
|
+
assetBundlePatterns: ["**/*"],
|
|
33
|
+
ios: {
|
|
34
|
+
supportsTablet: true,
|
|
35
|
+
bundleIdentifier: getUniqueIdentifier(),
|
|
36
|
+
infoPlist: {
|
|
37
|
+
UIBackgroundModes: ["remote-notification"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
android: {
|
|
41
|
+
adaptiveIcon: {
|
|
42
|
+
foregroundImage: "./assets/images/adaptive-icon.png",
|
|
43
|
+
backgroundColor: "#ffffff",
|
|
44
|
+
},
|
|
45
|
+
package: getUniqueIdentifier(),
|
|
46
|
+
permissions: ["NOTIFICATIONS"],
|
|
47
|
+
},
|
|
48
|
+
web: {
|
|
49
|
+
bundler: "metro",
|
|
50
|
+
output: "static",
|
|
51
|
+
favicon: "./assets/images/favicon.png",
|
|
52
|
+
},
|
|
53
|
+
plugins: [
|
|
54
|
+
"expo-router",
|
|
55
|
+
"expo-secure-store",
|
|
56
|
+
[
|
|
57
|
+
"expo-notifications",
|
|
58
|
+
{
|
|
59
|
+
icon: "./assets/images/notification-icon.png",
|
|
60
|
+
color: "#ffffff",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
],
|
|
64
|
+
experiments: {
|
|
65
|
+
typedRoutes: true,
|
|
66
|
+
},
|
|
67
|
+
extra: {
|
|
68
|
+
eas: {
|
|
69
|
+
projectId: "your-project-id",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/babel.config.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Component, ErrorInfo, ReactNode } from "react";
|
|
2
|
+
import { View, Text, Pressable, ScrollView } from "react-native";
|
|
3
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
4
|
+
import * as Updates from "expo-updates";
|
|
5
|
+
import { captureException, addBreadcrumb } from "@/services/sentry";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
fallback?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface State {
|
|
13
|
+
hasError: boolean;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
errorInfo: ErrorInfo | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Global Error Boundary component
|
|
20
|
+
* Catches JavaScript errors anywhere in the child component tree
|
|
21
|
+
* and displays a fallback UI instead of crashing the whole app
|
|
22
|
+
*/
|
|
23
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
24
|
+
constructor(props: Props) {
|
|
25
|
+
super(props);
|
|
26
|
+
this.state = {
|
|
27
|
+
hasError: false,
|
|
28
|
+
error: null,
|
|
29
|
+
errorInfo: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static getDerivedStateFromError(error: Error): Partial<State> {
|
|
34
|
+
return { hasError: true, error };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
38
|
+
this.setState({ errorInfo });
|
|
39
|
+
|
|
40
|
+
// Log to your error reporting service (Sentry, Bugsnag, etc.)
|
|
41
|
+
this.logError(error, errorInfo);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private logError(error: Error, errorInfo: ErrorInfo) {
|
|
45
|
+
// Add breadcrumb for context
|
|
46
|
+
addBreadcrumb("error-boundary", "Error caught by ErrorBoundary", {
|
|
47
|
+
componentStack: errorInfo.componentStack?.slice(0, 500),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Send to Sentry
|
|
51
|
+
captureException(error, {
|
|
52
|
+
componentStack: errorInfo.componentStack,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Also log to console in dev
|
|
56
|
+
console.error("ErrorBoundary caught an error:", error);
|
|
57
|
+
console.error("Component stack:", errorInfo.componentStack);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private handleRestart = async () => {
|
|
61
|
+
try {
|
|
62
|
+
// Try to reload the app using expo-updates
|
|
63
|
+
if (!__DEV__) {
|
|
64
|
+
await Updates.reloadAsync();
|
|
65
|
+
} else {
|
|
66
|
+
// In dev, just reset the error state
|
|
67
|
+
this.setState({
|
|
68
|
+
hasError: false,
|
|
69
|
+
error: null,
|
|
70
|
+
errorInfo: null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// If Updates.reloadAsync fails, reset state
|
|
75
|
+
this.setState({
|
|
76
|
+
hasError: false,
|
|
77
|
+
error: null,
|
|
78
|
+
errorInfo: null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
private handleReset = () => {
|
|
84
|
+
this.setState({
|
|
85
|
+
hasError: false,
|
|
86
|
+
error: null,
|
|
87
|
+
errorInfo: null,
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
render() {
|
|
92
|
+
if (this.state.hasError) {
|
|
93
|
+
if (this.props.fallback) {
|
|
94
|
+
return this.props.fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View className="flex-1 items-center justify-center bg-background-light px-6 dark:bg-background-dark">
|
|
99
|
+
{/* Error Icon */}
|
|
100
|
+
<View className="mb-6 h-24 w-24 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
|
101
|
+
<Ionicons name="warning-outline" size={48} color="#ef4444" />
|
|
102
|
+
</View>
|
|
103
|
+
|
|
104
|
+
{/* Title */}
|
|
105
|
+
<Text className="mb-2 text-center text-2xl font-bold text-text-light dark:text-text-dark">
|
|
106
|
+
Oops! Something went wrong
|
|
107
|
+
</Text>
|
|
108
|
+
|
|
109
|
+
{/* Description */}
|
|
110
|
+
<Text className="mb-8 text-center text-muted-light dark:text-muted-dark">
|
|
111
|
+
The app ran into a problem and could not continue. We apologize for
|
|
112
|
+
any inconvenience.
|
|
113
|
+
</Text>
|
|
114
|
+
|
|
115
|
+
{/* Error details (dev only) */}
|
|
116
|
+
{__DEV__ && this.state.error && (
|
|
117
|
+
<ScrollView className="mb-6 max-h-32 w-full rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
|
|
118
|
+
<Text className="font-mono text-xs text-red-600 dark:text-red-400">
|
|
119
|
+
{this.state.error.toString()}
|
|
120
|
+
</Text>
|
|
121
|
+
{this.state.errorInfo && (
|
|
122
|
+
<Text className="mt-2 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
123
|
+
{this.state.errorInfo.componentStack?.slice(0, 500)}...
|
|
124
|
+
</Text>
|
|
125
|
+
)}
|
|
126
|
+
</ScrollView>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Action buttons */}
|
|
130
|
+
<View className="w-full gap-3">
|
|
131
|
+
<Pressable
|
|
132
|
+
onPress={this.handleRestart}
|
|
133
|
+
className="w-full items-center rounded-xl bg-primary-600 py-4"
|
|
134
|
+
>
|
|
135
|
+
<Text className="font-semibold text-white">Restart App</Text>
|
|
136
|
+
</Pressable>
|
|
137
|
+
|
|
138
|
+
<Pressable
|
|
139
|
+
onPress={this.handleReset}
|
|
140
|
+
className="w-full items-center rounded-xl border-2 border-gray-300 py-4 dark:border-gray-600"
|
|
141
|
+
>
|
|
142
|
+
<Text className="font-semibold text-text-light dark:text-text-dark">
|
|
143
|
+
Try Again
|
|
144
|
+
</Text>
|
|
145
|
+
</Pressable>
|
|
146
|
+
</View>
|
|
147
|
+
</View>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return this.props.children;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* HOC to wrap any component with error boundary
|
|
157
|
+
*/
|
|
158
|
+
export function withErrorBoundary<P extends object>(
|
|
159
|
+
WrappedComponent: React.ComponentType<P>,
|
|
160
|
+
fallback?: ReactNode
|
|
161
|
+
) {
|
|
162
|
+
return function WithErrorBoundary(props: P) {
|
|
163
|
+
return (
|
|
164
|
+
<ErrorBoundary fallback={fallback}>
|
|
165
|
+
<WrappedComponent {...props} />
|
|
166
|
+
</ErrorBoundary>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { TextInput, TextInputProps } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
Controller,
|
|
5
|
+
Control,
|
|
6
|
+
FieldValues,
|
|
7
|
+
Path,
|
|
8
|
+
RegisterOptions,
|
|
9
|
+
} from "react-hook-form";
|
|
10
|
+
import { Input } from "@/components/ui/Input";
|
|
11
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
12
|
+
|
|
13
|
+
interface FormInputProps<T extends FieldValues>
|
|
14
|
+
extends Omit<TextInputProps, "value" | "onChangeText"> {
|
|
15
|
+
name: Path<T>;
|
|
16
|
+
control: Control<T>;
|
|
17
|
+
rules?: RegisterOptions<T, Path<T>>;
|
|
18
|
+
label?: string;
|
|
19
|
+
hint?: string;
|
|
20
|
+
leftIcon?: keyof typeof Ionicons.glyphMap;
|
|
21
|
+
rightIcon?: keyof typeof Ionicons.glyphMap;
|
|
22
|
+
onRightIconPress?: () => void;
|
|
23
|
+
containerClassName?: string;
|
|
24
|
+
inputClassName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Form-connected Input component using React Hook Form
|
|
29
|
+
* Automatically handles validation errors and form state
|
|
30
|
+
*/
|
|
31
|
+
export const FormInput = forwardRef(function FormInputInner<
|
|
32
|
+
T extends FieldValues
|
|
33
|
+
>(
|
|
34
|
+
{
|
|
35
|
+
name,
|
|
36
|
+
control,
|
|
37
|
+
rules,
|
|
38
|
+
label,
|
|
39
|
+
hint,
|
|
40
|
+
leftIcon,
|
|
41
|
+
rightIcon,
|
|
42
|
+
onRightIconPress,
|
|
43
|
+
containerClassName,
|
|
44
|
+
inputClassName,
|
|
45
|
+
...props
|
|
46
|
+
}: FormInputProps<T>,
|
|
47
|
+
ref: React.Ref<TextInput>
|
|
48
|
+
) {
|
|
49
|
+
return (
|
|
50
|
+
<Controller
|
|
51
|
+
name={name}
|
|
52
|
+
control={control}
|
|
53
|
+
rules={rules}
|
|
54
|
+
render={({
|
|
55
|
+
field: { onChange, onBlur, value },
|
|
56
|
+
fieldState: { error },
|
|
57
|
+
}) => (
|
|
58
|
+
<Input
|
|
59
|
+
ref={ref}
|
|
60
|
+
label={label}
|
|
61
|
+
value={value}
|
|
62
|
+
onChangeText={onChange}
|
|
63
|
+
onBlur={onBlur}
|
|
64
|
+
error={error?.message}
|
|
65
|
+
hint={hint}
|
|
66
|
+
leftIcon={leftIcon}
|
|
67
|
+
rightIcon={rightIcon}
|
|
68
|
+
onRightIconPress={onRightIconPress}
|
|
69
|
+
containerClassName={containerClassName}
|
|
70
|
+
inputClassName={inputClassName}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}) as <T extends FieldValues>(
|
|
77
|
+
props: FormInputProps<T> & { ref?: React.Ref<TextInput> }
|
|
78
|
+
) => JSX.Element;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FormInput } from "./FormInput";
|