@croacroa/react-native-template 2.1.0 → 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.
- package/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -21
- package/README.md +446 -402
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- 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/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -375
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -176
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- 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;
|