@croacroa/react-native-template 2.0.1 → 3.2.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 (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -0
  10. package/README.md +446 -399
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -0
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -23
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -27
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +64 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -0
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -175
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -1,370 +1,370 @@
1
- import { useRef, useState, useCallback } from "react";
2
- import {
3
- View,
4
- Text,
5
- FlatList,
6
- Dimensions,
7
- ViewToken,
8
- NativeSyntheticEvent,
9
- NativeScrollEvent,
10
- } from "react-native";
11
- import Animated, {
12
- useAnimatedStyle,
13
- useSharedValue,
14
- withSpring,
15
- interpolate,
16
- Extrapolation,
17
- } from "react-native-reanimated";
18
- import { router } from "expo-router";
19
- import { Ionicons } from "@expo/vector-icons";
20
- import { useTheme } from "@/hooks/useTheme";
21
- import { storage } from "@/services/storage";
22
- import { Button } from "@/components/ui/Button";
23
- import { cn } from "@/utils/cn";
24
- import { STORAGE_KEYS } from "@/constants/config";
25
-
26
- const { width: SCREEN_WIDTH } = Dimensions.get("window");
27
-
28
- export interface OnboardingSlide {
29
- id: string;
30
- title: string;
31
- description: string;
32
- icon: keyof typeof Ionicons.glyphMap;
33
- iconColor?: string;
34
- backgroundColor?: string;
35
- }
36
-
37
- interface OnboardingScreenProps {
38
- /**
39
- * Slides to display
40
- */
41
- slides?: OnboardingSlide[];
42
-
43
- /**
44
- * Called when onboarding is completed
45
- */
46
- onComplete?: () => void;
47
-
48
- /**
49
- * Whether to show skip button
50
- */
51
- showSkip?: boolean;
52
-
53
- /**
54
- * Text for the final button
55
- */
56
- finalButtonText?: string;
57
- }
58
-
59
- const DEFAULT_SLIDES: OnboardingSlide[] = [
60
- {
61
- id: "1",
62
- title: "Welcome to the App",
63
- description:
64
- "Discover a new way to manage your tasks and boost your productivity.",
65
- icon: "rocket-outline",
66
- iconColor: "#10b981",
67
- },
68
- {
69
- id: "2",
70
- title: "Stay Organized",
71
- description:
72
- "Keep all your important information in one place. Access it anywhere, anytime.",
73
- icon: "folder-outline",
74
- iconColor: "#3b82f6",
75
- },
76
- {
77
- id: "3",
78
- title: "Secure & Private",
79
- description:
80
- "Your data is encrypted and protected. Only you have access to your information.",
81
- icon: "shield-checkmark-outline",
82
- iconColor: "#8b5cf6",
83
- },
84
- {
85
- id: "4",
86
- title: "Ready to Start?",
87
- description:
88
- "Create your account and start your journey towards better productivity.",
89
- icon: "checkmark-circle-outline",
90
- iconColor: "#10b981",
91
- },
92
- ];
93
-
94
- export function OnboardingScreen({
95
- slides = DEFAULT_SLIDES,
96
- onComplete,
97
- showSkip = true,
98
- finalButtonText = "Get Started",
99
- }: OnboardingScreenProps) {
100
- const { isDark } = useTheme();
101
- const [currentIndex, setCurrentIndex] = useState(0);
102
- const flatListRef = useRef<FlatList>(null);
103
- const scrollX = useSharedValue(0);
104
-
105
- const isLastSlide = currentIndex === slides.length - 1;
106
-
107
- const handleComplete = useCallback(async () => {
108
- await storage.set(STORAGE_KEYS.ONBOARDING_COMPLETED, true);
109
- onComplete?.();
110
- router.replace("/(public)/login");
111
- }, [onComplete]);
112
-
113
- const handleSkip = useCallback(() => {
114
- handleComplete();
115
- }, [handleComplete]);
116
-
117
- const handleNext = useCallback(() => {
118
- if (isLastSlide) {
119
- handleComplete();
120
- } else {
121
- flatListRef.current?.scrollToIndex({
122
- index: currentIndex + 1,
123
- animated: true,
124
- });
125
- }
126
- }, [currentIndex, isLastSlide, handleComplete]);
127
-
128
- const handleScroll = useCallback(
129
- (event: NativeSyntheticEvent<NativeScrollEvent>) => {
130
- scrollX.value = event.nativeEvent.contentOffset.x;
131
- },
132
- [scrollX]
133
- );
134
-
135
- const onViewableItemsChanged = useCallback(
136
- ({ viewableItems }: { viewableItems: ViewToken[] }) => {
137
- if (viewableItems.length > 0 && viewableItems[0].index !== null) {
138
- setCurrentIndex(viewableItems[0].index);
139
- }
140
- },
141
- []
142
- );
143
-
144
- const viewabilityConfig = useRef({
145
- viewAreaCoveragePercentThreshold: 50,
146
- }).current;
147
-
148
- const renderSlide = ({
149
- item,
150
- index,
151
- }: {
152
- item: OnboardingSlide;
153
- index: number;
154
- }) => (
155
- <SlideItem item={item} index={index} scrollX={scrollX} isDark={isDark} />
156
- );
157
-
158
- return (
159
- <View
160
- className={cn(
161
- "flex-1",
162
- isDark ? "bg-background-dark" : "bg-background-light"
163
- )}
164
- >
165
- {/* Skip button */}
166
- {showSkip && !isLastSlide && (
167
- <View className="absolute top-16 right-6 z-10">
168
- <Button
169
- variant="ghost"
170
- size="sm"
171
- onPress={handleSkip}
172
- accessibilityLabel="Skip onboarding"
173
- accessibilityRole="button"
174
- >
175
- Skip
176
- </Button>
177
- </View>
178
- )}
179
-
180
- {/* Slides */}
181
- <FlatList
182
- ref={flatListRef}
183
- data={slides}
184
- renderItem={renderSlide}
185
- keyExtractor={(item) => item.id}
186
- horizontal
187
- pagingEnabled
188
- showsHorizontalScrollIndicator={false}
189
- bounces={false}
190
- onScroll={handleScroll}
191
- scrollEventThrottle={16}
192
- onViewableItemsChanged={onViewableItemsChanged}
193
- viewabilityConfig={viewabilityConfig}
194
- getItemLayout={(_, index) => ({
195
- length: SCREEN_WIDTH,
196
- offset: SCREEN_WIDTH * index,
197
- index,
198
- })}
199
- />
200
-
201
- {/* Bottom section */}
202
- <View className="px-6 pb-12">
203
- {/* Pagination dots */}
204
- <View className="flex-row justify-center items-center mb-8">
205
- {slides.map((_, index) => (
206
- <PaginationDot
207
- key={index}
208
- index={index}
209
- scrollX={scrollX}
210
- isDark={isDark}
211
- />
212
- ))}
213
- </View>
214
-
215
- {/* Action button */}
216
- <Button
217
- variant="primary"
218
- size="lg"
219
- onPress={handleNext}
220
- className="w-full"
221
- accessibilityLabel={isLastSlide ? finalButtonText : "Next slide"}
222
- accessibilityRole="button"
223
- >
224
- {isLastSlide ? finalButtonText : "Next"}
225
- </Button>
226
- </View>
227
- </View>
228
- );
229
- }
230
-
231
- /**
232
- * Individual slide component
233
- */
234
- interface SlideItemProps {
235
- item: OnboardingSlide;
236
- index: number;
237
- scrollX: Animated.SharedValue<number>;
238
- isDark: boolean;
239
- }
240
-
241
- function SlideItem({ item, index, scrollX, isDark }: SlideItemProps) {
242
- const animatedStyle = useAnimatedStyle(() => {
243
- const inputRange = [
244
- (index - 1) * SCREEN_WIDTH,
245
- index * SCREEN_WIDTH,
246
- (index + 1) * SCREEN_WIDTH,
247
- ];
248
-
249
- const scale = interpolate(
250
- scrollX.value,
251
- inputRange,
252
- [0.8, 1, 0.8],
253
- Extrapolation.CLAMP
254
- );
255
-
256
- const opacity = interpolate(
257
- scrollX.value,
258
- inputRange,
259
- [0.5, 1, 0.5],
260
- Extrapolation.CLAMP
261
- );
262
-
263
- const translateY = interpolate(
264
- scrollX.value,
265
- inputRange,
266
- [50, 0, 50],
267
- Extrapolation.CLAMP
268
- );
269
-
270
- return {
271
- transform: [{ scale }, { translateY }],
272
- opacity,
273
- };
274
- });
275
-
276
- return (
277
- <View
278
- style={{ width: SCREEN_WIDTH }}
279
- className="flex-1 items-center justify-center px-8"
280
- accessibilityRole="text"
281
- accessibilityLabel={`${item.title}. ${item.description}`}
282
- >
283
- <Animated.View style={animatedStyle} className="items-center">
284
- {/* Icon */}
285
- <View
286
- className={cn(
287
- "w-32 h-32 rounded-full items-center justify-center mb-8",
288
- isDark ? "bg-surface-dark" : "bg-gray-100"
289
- )}
290
- >
291
- <Ionicons
292
- name={item.icon}
293
- size={64}
294
- color={item.iconColor || "#10b981"}
295
- />
296
- </View>
297
-
298
- {/* Title */}
299
- <Text
300
- className={cn(
301
- "text-2xl font-bold text-center mb-4",
302
- isDark ? "text-text-dark" : "text-text-light"
303
- )}
304
- >
305
- {item.title}
306
- </Text>
307
-
308
- {/* Description */}
309
- <Text
310
- className={cn(
311
- "text-base text-center leading-6",
312
- isDark ? "text-muted-dark" : "text-muted-light"
313
- )}
314
- >
315
- {item.description}
316
- </Text>
317
- </Animated.View>
318
- </View>
319
- );
320
- }
321
-
322
- /**
323
- * Pagination dot component
324
- */
325
- interface PaginationDotProps {
326
- index: number;
327
- scrollX: Animated.SharedValue<number>;
328
- isDark: boolean;
329
- }
330
-
331
- function PaginationDot({ index, scrollX, isDark }: PaginationDotProps) {
332
- const animatedStyle = useAnimatedStyle(() => {
333
- const inputRange = [
334
- (index - 1) * SCREEN_WIDTH,
335
- index * SCREEN_WIDTH,
336
- (index + 1) * SCREEN_WIDTH,
337
- ];
338
-
339
- const width = interpolate(
340
- scrollX.value,
341
- inputRange,
342
- [8, 24, 8],
343
- Extrapolation.CLAMP
344
- );
345
-
346
- const opacity = interpolate(
347
- scrollX.value,
348
- inputRange,
349
- [0.4, 1, 0.4],
350
- Extrapolation.CLAMP
351
- );
352
-
353
- return {
354
- width: withSpring(width, { damping: 15, stiffness: 200 }),
355
- opacity,
356
- };
357
- });
358
-
359
- return (
360
- <Animated.View
361
- style={animatedStyle}
362
- className={cn(
363
- "h-2 rounded-full mx-1",
364
- isDark ? "bg-primary-400" : "bg-primary-500"
365
- )}
366
- />
367
- );
368
- }
369
-
370
- export default OnboardingScreen;
1
+ import { useRef, useState, useCallback } from "react";
2
+ import {
3
+ View,
4
+ Text,
5
+ FlatList,
6
+ Dimensions,
7
+ ViewToken,
8
+ NativeSyntheticEvent,
9
+ NativeScrollEvent,
10
+ } from "react-native";
11
+ import Animated, {
12
+ useAnimatedStyle,
13
+ useSharedValue,
14
+ withSpring,
15
+ interpolate,
16
+ Extrapolation,
17
+ } from "react-native-reanimated";
18
+ import { router } from "expo-router";
19
+ import { Ionicons } from "@expo/vector-icons";
20
+ import { useTheme } from "@/hooks/useTheme";
21
+ import { storage } from "@/services/storage";
22
+ import { Button } from "@/components/ui/Button";
23
+ import { cn } from "@/utils/cn";
24
+ import { STORAGE_KEYS } from "@/constants/config";
25
+
26
+ const { width: SCREEN_WIDTH } = Dimensions.get("window");
27
+
28
+ export interface OnboardingSlide {
29
+ id: string;
30
+ title: string;
31
+ description: string;
32
+ icon: keyof typeof Ionicons.glyphMap;
33
+ iconColor?: string;
34
+ backgroundColor?: string;
35
+ }
36
+
37
+ interface OnboardingScreenProps {
38
+ /**
39
+ * Slides to display
40
+ */
41
+ slides?: OnboardingSlide[];
42
+
43
+ /**
44
+ * Called when onboarding is completed
45
+ */
46
+ onComplete?: () => void;
47
+
48
+ /**
49
+ * Whether to show skip button
50
+ */
51
+ showSkip?: boolean;
52
+
53
+ /**
54
+ * Text for the final button
55
+ */
56
+ finalButtonText?: string;
57
+ }
58
+
59
+ const DEFAULT_SLIDES: OnboardingSlide[] = [
60
+ {
61
+ id: "1",
62
+ title: "Welcome to the App",
63
+ description:
64
+ "Discover a new way to manage your tasks and boost your productivity.",
65
+ icon: "rocket-outline",
66
+ iconColor: "#10b981",
67
+ },
68
+ {
69
+ id: "2",
70
+ title: "Stay Organized",
71
+ description:
72
+ "Keep all your important information in one place. Access it anywhere, anytime.",
73
+ icon: "folder-outline",
74
+ iconColor: "#3b82f6",
75
+ },
76
+ {
77
+ id: "3",
78
+ title: "Secure & Private",
79
+ description:
80
+ "Your data is encrypted and protected. Only you have access to your information.",
81
+ icon: "shield-checkmark-outline",
82
+ iconColor: "#8b5cf6",
83
+ },
84
+ {
85
+ id: "4",
86
+ title: "Ready to Start?",
87
+ description:
88
+ "Create your account and start your journey towards better productivity.",
89
+ icon: "checkmark-circle-outline",
90
+ iconColor: "#10b981",
91
+ },
92
+ ];
93
+
94
+ export function OnboardingScreen({
95
+ slides = DEFAULT_SLIDES,
96
+ onComplete,
97
+ showSkip = true,
98
+ finalButtonText = "Get Started",
99
+ }: OnboardingScreenProps) {
100
+ const { isDark } = useTheme();
101
+ const [currentIndex, setCurrentIndex] = useState(0);
102
+ const flatListRef = useRef<FlatList>(null);
103
+ const scrollX = useSharedValue(0);
104
+
105
+ const isLastSlide = currentIndex === slides.length - 1;
106
+
107
+ const handleComplete = useCallback(async () => {
108
+ await storage.set(STORAGE_KEYS.ONBOARDING_COMPLETED, true);
109
+ onComplete?.();
110
+ router.replace("/(public)/login");
111
+ }, [onComplete]);
112
+
113
+ const handleSkip = useCallback(() => {
114
+ handleComplete();
115
+ }, [handleComplete]);
116
+
117
+ const handleNext = useCallback(() => {
118
+ if (isLastSlide) {
119
+ handleComplete();
120
+ } else {
121
+ flatListRef.current?.scrollToIndex({
122
+ index: currentIndex + 1,
123
+ animated: true,
124
+ });
125
+ }
126
+ }, [currentIndex, isLastSlide, handleComplete]);
127
+
128
+ const handleScroll = useCallback(
129
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
130
+ scrollX.value = event.nativeEvent.contentOffset.x;
131
+ },
132
+ [scrollX]
133
+ );
134
+
135
+ const onViewableItemsChanged = useCallback(
136
+ ({ viewableItems }: { viewableItems: ViewToken[] }) => {
137
+ if (viewableItems.length > 0 && viewableItems[0].index !== null) {
138
+ setCurrentIndex(viewableItems[0].index);
139
+ }
140
+ },
141
+ []
142
+ );
143
+
144
+ const viewabilityConfig = useRef({
145
+ viewAreaCoveragePercentThreshold: 50,
146
+ }).current;
147
+
148
+ const renderSlide = ({
149
+ item,
150
+ index,
151
+ }: {
152
+ item: OnboardingSlide;
153
+ index: number;
154
+ }) => (
155
+ <SlideItem item={item} index={index} scrollX={scrollX} isDark={isDark} />
156
+ );
157
+
158
+ return (
159
+ <View
160
+ className={cn(
161
+ "flex-1",
162
+ isDark ? "bg-background-dark" : "bg-background-light"
163
+ )}
164
+ >
165
+ {/* Skip button */}
166
+ {showSkip && !isLastSlide && (
167
+ <View className="absolute top-16 right-6 z-10">
168
+ <Button
169
+ variant="ghost"
170
+ size="sm"
171
+ onPress={handleSkip}
172
+ accessibilityLabel="Skip onboarding"
173
+ accessibilityRole="button"
174
+ >
175
+ Skip
176
+ </Button>
177
+ </View>
178
+ )}
179
+
180
+ {/* Slides */}
181
+ <FlatList
182
+ ref={flatListRef}
183
+ data={slides}
184
+ renderItem={renderSlide}
185
+ keyExtractor={(item) => item.id}
186
+ horizontal
187
+ pagingEnabled
188
+ showsHorizontalScrollIndicator={false}
189
+ bounces={false}
190
+ onScroll={handleScroll}
191
+ scrollEventThrottle={16}
192
+ onViewableItemsChanged={onViewableItemsChanged}
193
+ viewabilityConfig={viewabilityConfig}
194
+ getItemLayout={(_, index) => ({
195
+ length: SCREEN_WIDTH,
196
+ offset: SCREEN_WIDTH * index,
197
+ index,
198
+ })}
199
+ />
200
+
201
+ {/* Bottom section */}
202
+ <View className="px-6 pb-12">
203
+ {/* Pagination dots */}
204
+ <View className="flex-row justify-center items-center mb-8">
205
+ {slides.map((_, index) => (
206
+ <PaginationDot
207
+ key={index}
208
+ index={index}
209
+ scrollX={scrollX}
210
+ isDark={isDark}
211
+ />
212
+ ))}
213
+ </View>
214
+
215
+ {/* Action button */}
216
+ <Button
217
+ variant="primary"
218
+ size="lg"
219
+ onPress={handleNext}
220
+ className="w-full"
221
+ accessibilityLabel={isLastSlide ? finalButtonText : "Next slide"}
222
+ accessibilityRole="button"
223
+ >
224
+ {isLastSlide ? finalButtonText : "Next"}
225
+ </Button>
226
+ </View>
227
+ </View>
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Individual slide component
233
+ */
234
+ interface SlideItemProps {
235
+ item: OnboardingSlide;
236
+ index: number;
237
+ scrollX: Animated.SharedValue<number>;
238
+ isDark: boolean;
239
+ }
240
+
241
+ function SlideItem({ item, index, scrollX, isDark }: SlideItemProps) {
242
+ const animatedStyle = useAnimatedStyle(() => {
243
+ const inputRange = [
244
+ (index - 1) * SCREEN_WIDTH,
245
+ index * SCREEN_WIDTH,
246
+ (index + 1) * SCREEN_WIDTH,
247
+ ];
248
+
249
+ const scale = interpolate(
250
+ scrollX.value,
251
+ inputRange,
252
+ [0.8, 1, 0.8],
253
+ Extrapolation.CLAMP
254
+ );
255
+
256
+ const opacity = interpolate(
257
+ scrollX.value,
258
+ inputRange,
259
+ [0.5, 1, 0.5],
260
+ Extrapolation.CLAMP
261
+ );
262
+
263
+ const translateY = interpolate(
264
+ scrollX.value,
265
+ inputRange,
266
+ [50, 0, 50],
267
+ Extrapolation.CLAMP
268
+ );
269
+
270
+ return {
271
+ transform: [{ scale }, { translateY }],
272
+ opacity,
273
+ };
274
+ });
275
+
276
+ return (
277
+ <View
278
+ style={{ width: SCREEN_WIDTH }}
279
+ className="flex-1 items-center justify-center px-8"
280
+ accessibilityRole="text"
281
+ accessibilityLabel={`${item.title}. ${item.description}`}
282
+ >
283
+ <Animated.View style={animatedStyle} className="items-center">
284
+ {/* Icon */}
285
+ <View
286
+ className={cn(
287
+ "w-32 h-32 rounded-full items-center justify-center mb-8",
288
+ isDark ? "bg-surface-dark" : "bg-gray-100"
289
+ )}
290
+ >
291
+ <Ionicons
292
+ name={item.icon}
293
+ size={64}
294
+ color={item.iconColor || "#10b981"}
295
+ />
296
+ </View>
297
+
298
+ {/* Title */}
299
+ <Text
300
+ className={cn(
301
+ "text-2xl font-bold text-center mb-4",
302
+ isDark ? "text-text-dark" : "text-text-light"
303
+ )}
304
+ >
305
+ {item.title}
306
+ </Text>
307
+
308
+ {/* Description */}
309
+ <Text
310
+ className={cn(
311
+ "text-base text-center leading-6",
312
+ isDark ? "text-muted-dark" : "text-muted-light"
313
+ )}
314
+ >
315
+ {item.description}
316
+ </Text>
317
+ </Animated.View>
318
+ </View>
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Pagination dot component
324
+ */
325
+ interface PaginationDotProps {
326
+ index: number;
327
+ scrollX: Animated.SharedValue<number>;
328
+ isDark: boolean;
329
+ }
330
+
331
+ function PaginationDot({ index, scrollX, isDark }: PaginationDotProps) {
332
+ const animatedStyle = useAnimatedStyle(() => {
333
+ const inputRange = [
334
+ (index - 1) * SCREEN_WIDTH,
335
+ index * SCREEN_WIDTH,
336
+ (index + 1) * SCREEN_WIDTH,
337
+ ];
338
+
339
+ const width = interpolate(
340
+ scrollX.value,
341
+ inputRange,
342
+ [8, 24, 8],
343
+ Extrapolation.CLAMP
344
+ );
345
+
346
+ const opacity = interpolate(
347
+ scrollX.value,
348
+ inputRange,
349
+ [0.4, 1, 0.4],
350
+ Extrapolation.CLAMP
351
+ );
352
+
353
+ return {
354
+ width: withSpring(width, { damping: 15, stiffness: 200 }),
355
+ opacity,
356
+ };
357
+ });
358
+
359
+ return (
360
+ <Animated.View
361
+ style={animatedStyle}
362
+ className={cn(
363
+ "h-2 rounded-full mx-1",
364
+ isDark ? "bg-primary-400" : "bg-primary-500"
365
+ )}
366
+ />
367
+ );
368
+ }
369
+
370
+ export default OnboardingScreen;