@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.
Files changed (109) hide show
  1. package/.env.example +18 -0
  2. package/.eslintrc.js +55 -0
  3. package/.github/workflows/ci.yml +184 -0
  4. package/.github/workflows/eas-build.yml +55 -0
  5. package/.github/workflows/eas-update.yml +50 -0
  6. package/.gitignore +62 -0
  7. package/.prettierrc +11 -0
  8. package/.storybook/main.ts +28 -0
  9. package/.storybook/preview.tsx +30 -0
  10. package/CHANGELOG.md +106 -0
  11. package/CONTRIBUTING.md +377 -0
  12. package/README.md +399 -0
  13. package/__tests__/components/Button.test.tsx +74 -0
  14. package/__tests__/hooks/useAuth.test.tsx +499 -0
  15. package/__tests__/services/api.test.ts +535 -0
  16. package/__tests__/utils/cn.test.ts +39 -0
  17. package/app/(auth)/_layout.tsx +36 -0
  18. package/app/(auth)/home.tsx +117 -0
  19. package/app/(auth)/profile.tsx +152 -0
  20. package/app/(auth)/settings.tsx +147 -0
  21. package/app/(public)/_layout.tsx +21 -0
  22. package/app/(public)/forgot-password.tsx +127 -0
  23. package/app/(public)/login.tsx +120 -0
  24. package/app/(public)/onboarding.tsx +5 -0
  25. package/app/(public)/register.tsx +139 -0
  26. package/app/_layout.tsx +97 -0
  27. package/app/index.tsx +21 -0
  28. package/app.config.ts +72 -0
  29. package/assets/images/.gitkeep +7 -0
  30. package/assets/images/adaptive-icon.png +0 -0
  31. package/assets/images/favicon.png +0 -0
  32. package/assets/images/icon.png +0 -0
  33. package/assets/images/notification-icon.png +0 -0
  34. package/assets/images/splash.png +0 -0
  35. package/babel.config.js +10 -0
  36. package/components/ErrorBoundary.tsx +169 -0
  37. package/components/forms/FormInput.tsx +78 -0
  38. package/components/forms/index.ts +1 -0
  39. package/components/onboarding/OnboardingScreen.tsx +370 -0
  40. package/components/onboarding/index.ts +2 -0
  41. package/components/ui/AnimatedButton.tsx +156 -0
  42. package/components/ui/AnimatedCard.tsx +108 -0
  43. package/components/ui/Avatar.tsx +316 -0
  44. package/components/ui/Badge.tsx +416 -0
  45. package/components/ui/BottomSheet.tsx +307 -0
  46. package/components/ui/Button.stories.tsx +115 -0
  47. package/components/ui/Button.tsx +104 -0
  48. package/components/ui/Card.stories.tsx +84 -0
  49. package/components/ui/Card.tsx +32 -0
  50. package/components/ui/Checkbox.tsx +261 -0
  51. package/components/ui/Input.stories.tsx +106 -0
  52. package/components/ui/Input.tsx +117 -0
  53. package/components/ui/Modal.tsx +98 -0
  54. package/components/ui/OptimizedImage.tsx +369 -0
  55. package/components/ui/Select.tsx +240 -0
  56. package/components/ui/Skeleton.tsx +180 -0
  57. package/components/ui/index.ts +18 -0
  58. package/constants/config.ts +54 -0
  59. package/docs/adr/001-state-management.md +79 -0
  60. package/docs/adr/002-styling-approach.md +130 -0
  61. package/docs/adr/003-data-fetching.md +155 -0
  62. package/docs/adr/004-auth-adapter-pattern.md +144 -0
  63. package/docs/adr/README.md +78 -0
  64. package/eas.json +47 -0
  65. package/global.css +10 -0
  66. package/hooks/index.ts +25 -0
  67. package/hooks/useApi.ts +236 -0
  68. package/hooks/useAuth.tsx +290 -0
  69. package/hooks/useBiometrics.ts +295 -0
  70. package/hooks/useDeepLinking.ts +256 -0
  71. package/hooks/useNotifications.ts +138 -0
  72. package/hooks/useOffline.ts +69 -0
  73. package/hooks/usePerformance.ts +434 -0
  74. package/hooks/useTheme.tsx +85 -0
  75. package/hooks/useUpdates.ts +358 -0
  76. package/i18n/index.ts +77 -0
  77. package/i18n/locales/en.json +101 -0
  78. package/i18n/locales/fr.json +101 -0
  79. package/jest.config.js +32 -0
  80. package/maestro/README.md +113 -0
  81. package/maestro/config.yaml +35 -0
  82. package/maestro/flows/login.yaml +62 -0
  83. package/maestro/flows/navigation.yaml +68 -0
  84. package/maestro/flows/offline.yaml +60 -0
  85. package/maestro/flows/register.yaml +94 -0
  86. package/metro.config.js +6 -0
  87. package/nativewind-env.d.ts +1 -0
  88. package/package.json +170 -0
  89. package/scripts/init.ps1 +162 -0
  90. package/scripts/init.sh +174 -0
  91. package/services/analytics.ts +428 -0
  92. package/services/api.ts +340 -0
  93. package/services/authAdapter.ts +333 -0
  94. package/services/index.ts +22 -0
  95. package/services/queryClient.ts +97 -0
  96. package/services/sentry.ts +131 -0
  97. package/services/storage.ts +82 -0
  98. package/stores/appStore.ts +54 -0
  99. package/stores/index.ts +2 -0
  100. package/stores/notificationStore.ts +40 -0
  101. package/tailwind.config.js +47 -0
  102. package/tsconfig.json +26 -0
  103. package/types/index.ts +42 -0
  104. package/types/user.ts +63 -0
  105. package/utils/accessibility.ts +446 -0
  106. package/utils/cn.ts +14 -0
  107. package/utils/index.ts +43 -0
  108. package/utils/toast.ts +113 -0
  109. package/utils/validation.ts +67 -0
@@ -0,0 +1,256 @@
1
+ import { useEffect, useCallback } from "react";
2
+ import * as Linking from "expo-linking";
3
+ import { router } from "expo-router";
4
+
5
+ // Define your app's deep link routes
6
+ type DeepLinkRoute =
7
+ | { path: "login"; params?: never }
8
+ | { path: "register"; params?: never }
9
+ | { path: "reset-password"; params: { token: string } }
10
+ | { path: "profile"; params?: { userId?: string } }
11
+ | { path: "settings"; params?: never }
12
+ | { path: "post"; params: { postId: string } };
13
+
14
+ interface ParsedDeepLink {
15
+ route: DeepLinkRoute | null;
16
+ rawUrl: string;
17
+ }
18
+
19
+ /**
20
+ * Parse a deep link URL into a route and params
21
+ */
22
+ function parseDeepLink(url: string): ParsedDeepLink {
23
+ try {
24
+ const parsed = Linking.parse(url);
25
+ const { path, queryParams } = parsed;
26
+
27
+ if (!path) {
28
+ return { route: null, rawUrl: url };
29
+ }
30
+
31
+ // Map URL paths to app routes
32
+ switch (path) {
33
+ case "login":
34
+ return { route: { path: "login" }, rawUrl: url };
35
+
36
+ case "register":
37
+ case "signup":
38
+ return { route: { path: "register" }, rawUrl: url };
39
+
40
+ case "reset-password":
41
+ if (queryParams?.token && typeof queryParams.token === "string") {
42
+ return {
43
+ route: {
44
+ path: "reset-password",
45
+ params: { token: queryParams.token },
46
+ },
47
+ rawUrl: url,
48
+ };
49
+ }
50
+ return { route: null, rawUrl: url };
51
+
52
+ case "profile":
53
+ return {
54
+ route: {
55
+ path: "profile",
56
+ params: queryParams?.userId
57
+ ? { userId: String(queryParams.userId) }
58
+ : undefined,
59
+ },
60
+ rawUrl: url,
61
+ };
62
+
63
+ case "settings":
64
+ return { route: { path: "settings" }, rawUrl: url };
65
+
66
+ case "post":
67
+ if (queryParams?.id && typeof queryParams.id === "string") {
68
+ return {
69
+ route: { path: "post", params: { postId: queryParams.id } },
70
+ rawUrl: url,
71
+ };
72
+ }
73
+ return { route: null, rawUrl: url };
74
+
75
+ default: {
76
+ // Try to handle paths like /post/123
77
+ const pathParts = path.split("/");
78
+ if (pathParts[0] === "post" && pathParts[1]) {
79
+ return {
80
+ route: { path: "post", params: { postId: pathParts[1] } },
81
+ rawUrl: url,
82
+ };
83
+ }
84
+ if (pathParts[0] === "profile" && pathParts[1]) {
85
+ return {
86
+ route: { path: "profile", params: { userId: pathParts[1] } },
87
+ rawUrl: url,
88
+ };
89
+ }
90
+ return { route: null, rawUrl: url };
91
+ }
92
+ }
93
+ } catch (error) {
94
+ console.error("Failed to parse deep link:", error);
95
+ return { route: null, rawUrl: url };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Navigate to a deep link route
101
+ */
102
+ function navigateToRoute(route: DeepLinkRoute): void {
103
+ switch (route.path) {
104
+ case "login":
105
+ router.replace("/(public)/login");
106
+ break;
107
+
108
+ case "register":
109
+ router.replace("/(public)/register");
110
+ break;
111
+
112
+ case "reset-password":
113
+ // Navigate to forgot password with token
114
+ router.push({
115
+ pathname: "/(public)/forgot-password",
116
+ params: { token: route.params.token },
117
+ });
118
+ break;
119
+
120
+ case "profile":
121
+ if (route.params?.userId) {
122
+ // Navigate to specific user profile
123
+ router.push({
124
+ pathname: "/(auth)/profile",
125
+ params: { userId: route.params.userId },
126
+ });
127
+ } else {
128
+ // Navigate to own profile
129
+ router.push("/(auth)/profile");
130
+ }
131
+ break;
132
+
133
+ case "settings":
134
+ router.push("/(auth)/settings");
135
+ break;
136
+
137
+ case "post":
138
+ // You'll need to create this route
139
+ router.push({
140
+ pathname: "/(auth)/post/[id]" as const,
141
+ params: { id: route.params.postId },
142
+ } as Parameters<typeof router.push>[0]);
143
+ break;
144
+ }
145
+ }
146
+
147
+ interface UseDeepLinkingOptions {
148
+ /**
149
+ * Called when a deep link is received but couldn't be parsed
150
+ */
151
+ onUnknownLink?: (url: string) => void;
152
+
153
+ /**
154
+ * Called before navigating to allow custom handling
155
+ * Return false to prevent default navigation
156
+ */
157
+ onBeforeNavigate?: (route: DeepLinkRoute) => boolean | void;
158
+
159
+ /**
160
+ * Whether deep linking is enabled
161
+ * @default true
162
+ */
163
+ enabled?: boolean;
164
+ }
165
+
166
+ /**
167
+ * Hook to handle deep links in your app
168
+ *
169
+ * @example
170
+ * ```tsx
171
+ * function App() {
172
+ * useDeepLinking({
173
+ * onUnknownLink: (url) => console.log('Unknown link:', url),
174
+ * onBeforeNavigate: (route) => {
175
+ * // Custom validation before navigating
176
+ * if (route.path === 'profile' && !isAuthenticated) {
177
+ * router.push('/login');
178
+ * return false; // Prevent default navigation
179
+ * }
180
+ * },
181
+ * });
182
+ *
183
+ * return <App />;
184
+ * }
185
+ * ```
186
+ */
187
+ export function useDeepLinking(options: UseDeepLinkingOptions = {}): void {
188
+ const { onUnknownLink, onBeforeNavigate, enabled = true } = options;
189
+
190
+ const handleDeepLink = useCallback(
191
+ (event: { url: string }) => {
192
+ if (!enabled) return;
193
+
194
+ const { route, rawUrl } = parseDeepLink(event.url);
195
+
196
+ if (!route) {
197
+ onUnknownLink?.(rawUrl);
198
+ console.log("Unknown deep link:", rawUrl);
199
+ return;
200
+ }
201
+
202
+ // Allow custom handling before navigation
203
+ const shouldNavigate = onBeforeNavigate?.(route);
204
+ if (shouldNavigate === false) {
205
+ return;
206
+ }
207
+
208
+ navigateToRoute(route);
209
+ },
210
+ [enabled, onUnknownLink, onBeforeNavigate]
211
+ );
212
+
213
+ useEffect(() => {
214
+ if (!enabled) return;
215
+
216
+ // Handle deep links when app is already open
217
+ const subscription = Linking.addEventListener("url", handleDeepLink);
218
+
219
+ // Handle deep link that opened the app
220
+ Linking.getInitialURL().then((url) => {
221
+ if (url) {
222
+ handleDeepLink({ url });
223
+ }
224
+ });
225
+
226
+ return () => {
227
+ subscription.remove();
228
+ };
229
+ }, [enabled, handleDeepLink]);
230
+ }
231
+
232
+ /**
233
+ * Get the app's deep link URL prefix
234
+ */
235
+ export function getDeepLinkPrefix(): string {
236
+ return Linking.createURL("/");
237
+ }
238
+
239
+ /**
240
+ * Create a deep link URL for the app
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * const url = createDeepLink('profile', { userId: '123' });
245
+ * // Returns: yourapp://profile?userId=123
246
+ * ```
247
+ */
248
+ export function createDeepLink(
249
+ path: string,
250
+ params?: Record<string, string>
251
+ ): string {
252
+ return Linking.createURL(path, { queryParams: params });
253
+ }
254
+
255
+ export { parseDeepLink, navigateToRoute };
256
+ export type { DeepLinkRoute, ParsedDeepLink };
@@ -0,0 +1,138 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Platform } from "react-native";
3
+ import * as Notifications from "expo-notifications";
4
+ import * as Device from "expo-device";
5
+ import Constants from "expo-constants";
6
+
7
+ import { useNotificationStore } from "@/stores/notificationStore";
8
+
9
+ // Configure how notifications should be handled when app is in foreground
10
+ Notifications.setNotificationHandler({
11
+ handleNotification: async () => ({
12
+ shouldShowAlert: true,
13
+ shouldPlaySound: true,
14
+ shouldSetBadge: true,
15
+ }),
16
+ });
17
+
18
+ export function useNotifications() {
19
+ const notificationListener = useRef<Notifications.Subscription>();
20
+ const responseListener = useRef<Notifications.Subscription>();
21
+ const { setPushToken, setLastNotification } = useNotificationStore();
22
+
23
+ useEffect(() => {
24
+ // Listen for incoming notifications
25
+ notificationListener.current =
26
+ Notifications.addNotificationReceivedListener((notification) => {
27
+ setLastNotification(notification);
28
+ });
29
+
30
+ // Listen for notification interactions
31
+ responseListener.current =
32
+ Notifications.addNotificationResponseReceivedListener((response) => {
33
+ const data = response.notification.request.content.data;
34
+ handleNotificationPress(data);
35
+ });
36
+
37
+ return () => {
38
+ if (notificationListener.current) {
39
+ Notifications.removeNotificationSubscription(
40
+ notificationListener.current
41
+ );
42
+ }
43
+ if (responseListener.current) {
44
+ Notifications.removeNotificationSubscription(responseListener.current);
45
+ }
46
+ };
47
+ }, []);
48
+
49
+ const handleNotificationPress = (data: Record<string, unknown>) => {
50
+ // TODO: Handle notification navigation based on data
51
+ // Example: router.push(data.screen as string);
52
+ console.log("Notification pressed with data:", data);
53
+ };
54
+
55
+ const registerForPushNotifications = async () => {
56
+ if (!Device.isDevice) {
57
+ console.log("Push notifications require a physical device");
58
+ return null;
59
+ }
60
+
61
+ // Check existing permissions
62
+ const { status: existingStatus } =
63
+ await Notifications.getPermissionsAsync();
64
+ let finalStatus = existingStatus;
65
+
66
+ // Request permissions if not granted
67
+ if (existingStatus !== "granted") {
68
+ const { status } = await Notifications.requestPermissionsAsync();
69
+ finalStatus = status;
70
+ }
71
+
72
+ if (finalStatus !== "granted") {
73
+ console.log("Push notification permission not granted");
74
+ return null;
75
+ }
76
+
77
+ // Get the push token
78
+ try {
79
+ const projectId = Constants.expoConfig?.extra?.eas?.projectId;
80
+ const token = await Notifications.getExpoPushTokenAsync({
81
+ projectId,
82
+ });
83
+
84
+ setPushToken(token.data);
85
+
86
+ // Configure Android channel
87
+ if (Platform.OS === "android") {
88
+ await Notifications.setNotificationChannelAsync("default", {
89
+ name: "Default",
90
+ importance: Notifications.AndroidImportance.MAX,
91
+ vibrationPattern: [0, 250, 250, 250],
92
+ lightColor: "#3b82f6",
93
+ });
94
+ }
95
+
96
+ return token.data;
97
+ } catch (error) {
98
+ console.error("Failed to get push token:", error);
99
+ return null;
100
+ }
101
+ };
102
+
103
+ const scheduleLocalNotification = async (
104
+ title: string,
105
+ body: string,
106
+ data?: Record<string, unknown>,
107
+ trigger?: Notifications.NotificationTriggerInput
108
+ ) => {
109
+ await Notifications.scheduleNotificationAsync({
110
+ content: {
111
+ title,
112
+ body,
113
+ data: data || {},
114
+ },
115
+ trigger: trigger || null, // null = immediate
116
+ });
117
+ };
118
+
119
+ const cancelAllNotifications = async () => {
120
+ await Notifications.cancelAllScheduledNotificationsAsync();
121
+ };
122
+
123
+ const getBadgeCount = async () => {
124
+ return Notifications.getBadgeCountAsync();
125
+ };
126
+
127
+ const setBadgeCount = async (count: number) => {
128
+ await Notifications.setBadgeCountAsync(count);
129
+ };
130
+
131
+ return {
132
+ registerForPushNotifications,
133
+ scheduleLocalNotification,
134
+ cancelAllNotifications,
135
+ getBadgeCount,
136
+ setBadgeCount,
137
+ };
138
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useState } from "react";
2
+ import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
3
+ import { onlineManager } from "@tanstack/react-query";
4
+ import { toast } from "@/utils/toast";
5
+
6
+ interface UseOfflineOptions {
7
+ showToast?: boolean;
8
+ }
9
+
10
+ /**
11
+ * Hook to track online/offline status
12
+ * Integrates with TanStack Query's online manager
13
+ */
14
+ export function useOffline(options: UseOfflineOptions = {}) {
15
+ const { showToast = true } = options;
16
+ const [isOnline, setIsOnline] = useState(true);
17
+ const [isInitialized, setIsInitialized] = useState(false);
18
+
19
+ useEffect(() => {
20
+ // Get initial state
21
+ NetInfo.fetch().then((state) => {
22
+ const online = !!state.isConnected;
23
+ setIsOnline(online);
24
+ setIsInitialized(true);
25
+ onlineManager.setOnline(online);
26
+ });
27
+
28
+ // Subscribe to changes
29
+ const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
30
+ const online = !!state.isConnected;
31
+ const wasOnline = isOnline;
32
+
33
+ setIsOnline(online);
34
+ onlineManager.setOnline(online);
35
+
36
+ // Show toast on status change (after initialization)
37
+ if (showToast && isInitialized) {
38
+ if (!online && wasOnline) {
39
+ toast.info("You're offline", "Some features may be limited");
40
+ } else if (online && !wasOnline) {
41
+ toast.success("Back online", "Syncing data...");
42
+ }
43
+ }
44
+ });
45
+
46
+ return () => unsubscribe();
47
+ }, [isOnline, isInitialized, showToast]);
48
+
49
+ return {
50
+ isOnline,
51
+ isOffline: !isOnline,
52
+ isInitialized,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Hook for pending mutations count
58
+ * Useful to show sync indicator
59
+ */
60
+ export function usePendingMutations() {
61
+ const [pendingCount, setPendingCount] = useState(0);
62
+
63
+ // This would need to be integrated with mutation cache
64
+ // For now, return 0 as a placeholder
65
+ return {
66
+ pendingCount,
67
+ hasPending: pendingCount > 0,
68
+ };
69
+ }