@rubixscript/react-native-onboarding 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.
@@ -0,0 +1,340 @@
1
+ import React, { useState, useCallback, useMemo, useEffect } from 'react';
2
+ import {
3
+ View,
4
+ StyleSheet,
5
+ SafeAreaView,
6
+ Platform,
7
+ Modal,
8
+ ViewStyle,
9
+ Animated,
10
+ } from 'react-native';
11
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
12
+ import AnimatedFlatList, {
13
+ FlatList,
14
+ AnimatedFlatListType,
15
+ } from 'react-native-reanimated';
16
+ import { BlurView } from 'expo-blur';
17
+ import { LinearGradient } from 'expo-linear-gradient';
18
+ import AsyncStorage from '@react-native-async-storage/async-storage';
19
+
20
+ import { OnboardingProps, OnboardingConfig } from '../types';
21
+ import { mergeTheme, getPreset } from '../themes';
22
+ import { SlideRenderer } from '../slides';
23
+ import { Pagination, NavigationButtons } from './index';
24
+
25
+ const AnimatedFlatListImplemented =
26
+ (AnimatedFlatList as any) as AnimatedFlatListType<any>;
27
+
28
+ export const Onboarding: React.FC<OnboardingProps> = ({
29
+ visible,
30
+ slides = [],
31
+ theme: customTheme,
32
+ navigation: customNavigation,
33
+ animation: customAnimation,
34
+ storage,
35
+ onboardingComplete,
36
+ onSlideChange,
37
+ onSkip,
38
+ initialSlide = 0,
39
+ swipeEnabled = true,
40
+ containerStyle,
41
+ safeAreaEnabled = true,
42
+ darkMode = false,
43
+ }) => {
44
+ // State
45
+ const [currentIndex, setCurrentIndex] = useState(initialSlide);
46
+ const [isSubmitting, setIsSubmitting] = useState(false);
47
+ const [formData, setFormData] = useState<Record<string, any>>({});
48
+
49
+ //Refs
50
+ const flatListRef = React.useRef<FlatList>(null);
51
+
52
+ // Merge theme with preset
53
+ const preset = useMemo(() => {
54
+ if (!customTheme) return getPreset('modern');
55
+ const presetKey = Object.keys(getPreset('modern')).find(
56
+ key => (customTheme as any)[key]
57
+ );
58
+ return presetKey ? getPreset(presetKey) : getPreset('modern');
59
+ }, [customTheme]);
60
+
61
+ const theme = useMemo(
62
+ () => mergeTheme(preset.theme, customTheme),
63
+ [preset, customTheme]
64
+ );
65
+
66
+ const navigationConfig = useMemo(
67
+ () => ({ ...preset.navigation, ...customNavigation }),
68
+ [preset, customNavigation]
69
+ );
70
+
71
+ const animationConfig = useMemo(
72
+ () => ({ ...preset.animation, ...customAnimation }),
73
+ [preset, customAnimation]
74
+ );
75
+
76
+ // Check storage on mount
77
+ useEffect(() => {
78
+ if (storage?.enabled) {
79
+ checkOnboardingStatus();
80
+ }
81
+ }, [storage]);
82
+
83
+ const checkOnboardingStatus = async () => {
84
+ try {
85
+ const key = storage?.key || '@onboarding_complete';
86
+ const completed = await AsyncStorage.getItem(key);
87
+ if (completed && !visible) {
88
+ // Already completed, don't show
89
+ return;
90
+ }
91
+ } catch (error) {
92
+ console.warn('Error checking onboarding status:', error);
93
+ }
94
+ };
95
+
96
+ const saveOnboardingComplete = async () => {
97
+ if (storage?.enabled) {
98
+ try {
99
+ const key = storage?.key || '@onboarding_complete';
100
+ await AsyncStorage.setItem(key, 'true');
101
+ if (storage.onComplete) {
102
+ await storage.onComplete();
103
+ }
104
+ } catch (error) {
105
+ console.warn('Error saving onboarding status:', error);
106
+ }
107
+ }
108
+ };
109
+
110
+ // Handlers
111
+ const handleNext = useCallback(async () => {
112
+ const currentSlide = slides[currentIndex];
113
+
114
+ // Check if form slide and validate
115
+ if (currentSlide.type === 'form') {
116
+ const formSlide = currentSlide as any;
117
+ const requiredFields = formSlide.fields.filter((f: any) => f.required);
118
+ const isValid = requiredFields.every((field: any) => formData[field.key]);
119
+
120
+ if (!isValid) {
121
+ return; // Form validation failed
122
+ }
123
+
124
+ // Submit form data
125
+ if (formSlide.onSubmit) {
126
+ setIsSubmitting(true);
127
+ try {
128
+ await formSlide.onSubmit(formData);
129
+ } catch (error) {
130
+ console.warn('Form submit error:', error);
131
+ }
132
+ setIsSubmitting(false);
133
+ }
134
+ }
135
+
136
+ if (currentIndex < slides.length - 1) {
137
+ flatListRef.current?.scrollToIndex({ index: currentIndex + 1, animated: true });
138
+ } else {
139
+ // Complete onboarding
140
+ await saveOnboardingComplete();
141
+ if (onboardingComplete) {
142
+ await onboardingComplete(formData);
143
+ }
144
+ }
145
+ }, [currentIndex, slides, formData, onboardingComplete, storage]);
146
+
147
+ const handleBack = useCallback(() => {
148
+ if (currentIndex > 0) {
149
+ flatListRef.current?.scrollToIndex({ index: currentIndex - 1, animated: true });
150
+ }
151
+ }, [currentIndex]);
152
+
153
+ const handleSkip = useCallback(async () => {
154
+ await saveOnboardingComplete();
155
+ if (onSkip) {
156
+ await onSkip();
157
+ } else if (onboardingComplete) {
158
+ await onboardingComplete();
159
+ }
160
+ }, [onSkip, onboardingComplete, storage]);
161
+
162
+ const handleMomentumScrollEnd = useCallback(
163
+ (event: any) => {
164
+ const index = Math.round(event.nativeEvent.contentOffset.x / event.nativeEvent.layoutMeasurement.width);
165
+ if (index !== currentIndex) {
166
+ setCurrentIndex(index);
167
+ if (onSlideChange) {
168
+ onSlideChange(index);
169
+ }
170
+ }
171
+ },
172
+ [currentIndex, onSlideChange]
173
+ );
174
+
175
+ const handleFormDataChange = useCallback((key: string, value: any) => {
176
+ setFormData(prev => ({ ...prev, [key]: value }));
177
+ }, []);
178
+
179
+ // Renderers
180
+ const renderSlide = useCallback(
181
+ ({ item, index }: { item: any; index: number }) => {
182
+ return (
183
+ <View style={[styles.slide, { width: '100%' }]}>
184
+ <SlideRenderer
185
+ data={item}
186
+ theme={theme}
187
+ darkMode={darkMode}
188
+ onSubmit={handleFormDataChange}
189
+ isSubmitting={isSubmitting}
190
+ />
191
+ </View>
192
+ );
193
+ },
194
+ [theme, darkMode, isSubmitting, handleFormDataChange]
195
+ );
196
+
197
+ const getKey = useCallback((item: any, index: number) => item.id || `slide-${index}`, []);
198
+
199
+ // Computed values
200
+ const isLastSlide = currentIndex === slides.length - 1;
201
+ const currentSlide = slides[currentIndex];
202
+
203
+ // Determine if we should show navigation (not for form slides that handle their own)
204
+ const showNavigation = currentSlide?.type !== 'form';
205
+
206
+ if (!visible) return null;
207
+
208
+ const content = (
209
+ <View style={[styles.container, containerStyle]}>
210
+ {/* Background Gradient if applicable */}
211
+ {currentSlide?.gradientColors && currentSlide.gradientColors.length > 0 && (
212
+ <LinearGradient
213
+ colors={currentSlide.gradientColors}
214
+ style={StyleSheet.absoluteFillObject}
215
+ />
216
+ )}
217
+
218
+ {/* Pagination */}
219
+ {showNavigation && (
220
+ <Pagination
221
+ currentIndex={currentIndex}
222
+ totalSlides={slides.length}
223
+ theme={theme}
224
+ config={navigationConfig}
225
+ />
226
+ )}
227
+
228
+ {/* Slides */}
229
+ <AnimatedFlatListImplemented
230
+ ref={flatListRef as any}
231
+ data={slides}
232
+ renderItem={renderSlide}
233
+ keyExtractor={getKey}
234
+ horizontal
235
+ pagingEnabled
236
+ showsHorizontalScrollIndicator={false}
237
+ scrollEventThrottle={32}
238
+ onMomentumScrollEnd={handleMomentumScrollEnd}
239
+ scrollEnabled={swipeEnabled}
240
+ bounces={false}
241
+ initialScrollIndex={initialSlide}
242
+ onScrollToIndexFailed={(info) => {
243
+ // Retry if scroll to index fails
244
+ setTimeout(() => {
245
+ flatListRef.current?.scrollToIndex({
246
+ index: info.index,
247
+ animated: true,
248
+ });
249
+ }, 100);
250
+ }}
251
+ />
252
+
253
+ {/* Navigation Buttons */}
254
+ {showNavigation && (
255
+ <NavigationButtons
256
+ currentIndex={currentIndex}
257
+ totalSlides={slides.length}
258
+ theme={theme}
259
+ config={navigationConfig}
260
+ onNext={handleNext}
261
+ onBack={handleBack}
262
+ onSkip={handleSkip}
263
+ isLastSlide={isLastSlide}
264
+ isLoading={isSubmitting}
265
+ darkMode={darkMode}
266
+ />
267
+ )}
268
+ </View>
269
+ );
270
+
271
+ if (safeAreaEnabled) {
272
+ return (
273
+ <SafeAreaView style={styles.safeArea}>
274
+ <SafeAreaProvider>{content}</SafeAreaProvider>
275
+ </SafeAreaView>
276
+ );
277
+ }
278
+
279
+ return content;
280
+ };
281
+
282
+ // Hook for checking onboarding status
283
+ export const useOnboarding = (storageKey: string = '@onboarding_complete') => {
284
+ const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState<boolean | null>(null);
285
+ const [isLoading, setIsLoading] = React.useState(true);
286
+
287
+ React.useEffect(() => {
288
+ checkStatus();
289
+ }, []);
290
+
291
+ const checkStatus = async () => {
292
+ try {
293
+ const completed = await AsyncStorage.getItem(storageKey);
294
+ setHasCompletedOnboarding(completed === 'true');
295
+ } catch (error) {
296
+ console.warn('Error checking onboarding:', error);
297
+ setHasCompletedOnboarding(false);
298
+ } finally {
299
+ setIsLoading(false);
300
+ }
301
+ };
302
+
303
+ const markComplete = async () => {
304
+ try {
305
+ await AsyncStorage.setItem(storageKey, 'true');
306
+ setHasCompletedOnboarding(true);
307
+ } catch (error) {
308
+ console.warn('Error marking onboarding complete:', error);
309
+ }
310
+ };
311
+
312
+ const reset = async () => {
313
+ try {
314
+ await AsyncStorage.removeItem(storageKey);
315
+ setHasCompletedOnboarding(false);
316
+ } catch (error) {
317
+ console.warn('Error resetting onboarding:', error);
318
+ }
319
+ };
320
+
321
+ return { hasCompletedOnboarding, isLoading, markComplete, reset };
322
+ };
323
+
324
+ const styles = StyleSheet.create({
325
+ safeArea: {
326
+ flex: 1,
327
+ backgroundColor: '#FFFFFF',
328
+ },
329
+ container: {
330
+ flex: 1,
331
+ backgroundColor: '#FFFFFF',
332
+ },
333
+ slide: {
334
+ flex: 1,
335
+ justifyContent: 'center',
336
+ alignItems: 'center',
337
+ },
338
+ });
339
+
340
+ export default Onboarding;
@@ -0,0 +1,337 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, StyleSheet, ViewStyle, TextStyle } from 'react-native';
3
+ import Animated, { useAnimatedStyle, withSpring, useDerivedValue } from 'react-native-reanimated';
4
+ import { LinearGradient } from 'expo-linear-gradient';
5
+ import { PaginationProps, NavigationStyle } from '../types';
6
+
7
+ // DOTS PAGINATION
8
+ const DotsPagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, style }) => {
9
+ const animatedIndex = useDerivedValue(() => withSpring(currentIndex, { damping: 15, stiffness: 150 }));
10
+
11
+ return (
12
+ <View style={[styles.dotsContainer, style]}>
13
+ {Array.from({ length: totalSlides }).map((_, index) => {
14
+ const animatedStyle = useAnimatedStyle(() => ({
15
+ width: index === animatedIndex.value ? 24 : 8,
16
+ opacity: withSpring(index === animatedIndex.value ? 1 : 0.4, { damping: 15, stiffness: 150 }),
17
+ }));
18
+
19
+ return (
20
+ <Animated.View
21
+ key={index}
22
+ style={[
23
+ styles.dot,
24
+ {
25
+ backgroundColor: theme.colors.primary,
26
+ },
27
+ animatedStyle,
28
+ ]}
29
+ />
30
+ );
31
+ })}
32
+ </View>
33
+ );
34
+ };
35
+
36
+ // PROGRESS BAR PAGINATION
37
+ const ProgressBarPagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, style }) => {
38
+ const progress = useMemo(() => (currentIndex + 1) / totalSlides, [currentIndex, totalSlides]);
39
+
40
+ return (
41
+ <View style={[styles.progressContainer, style]}>
42
+ <View style={[styles.progressBackground, { backgroundColor: theme.colors.border }]}>
43
+ <Animated.View
44
+ style={[
45
+ styles.progressFill,
46
+ {
47
+ width: `${progress * 100}%`,
48
+ backgroundColor: theme.colors.primary,
49
+ },
50
+ ]}
51
+ />
52
+ </View>
53
+ <Animated.Text style={[styles.progressText, { color: theme.colors.text.secondary }]}>
54
+ {currentIndex + 1} / {totalSlides}
55
+ </Animated.Text>
56
+ </View>
57
+ );
58
+ };
59
+
60
+ // STEPS PAGINATION
61
+ const StepsPagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, style }) => {
62
+ return (
63
+ <View style={[styles.stepsContainer, style]}>
64
+ {Array.from({ length: totalSlides }).map((_, index) => {
65
+ const isCompleted = index < currentIndex;
66
+ const isCurrent = index === currentIndex;
67
+
68
+ return (
69
+ <View key={index} style={styles.stepItem}>
70
+ <View
71
+ style={[
72
+ styles.stepCircle,
73
+ {
74
+ backgroundColor: isCompleted ? theme.colors.primary : isCurrent ? theme.colors.primary : theme.colors.border,
75
+ borderColor: theme.colors.border,
76
+ },
77
+ ]}
78
+ >
79
+ {isCompleted ? (
80
+ <View style={styles.stepCheckmark} />
81
+ ) : (
82
+ <Animated.Text
83
+ style={[
84
+ styles.stepNumber,
85
+ { color: isCurrent ? theme.colors.text.inverse : theme.colors.text.secondary },
86
+ ]}
87
+ >
88
+ {index + 1}
89
+ </Animated.Text>
90
+ )}
91
+ </View>
92
+ {index < totalSlides - 1 && (
93
+ <View
94
+ style={[
95
+ styles.stepLine,
96
+ { backgroundColor: isCompleted ? theme.colors.primary : theme.colors.border },
97
+ ]}
98
+ />
99
+ )}
100
+ </View>
101
+ );
102
+ })}
103
+ </View>
104
+ );
105
+ };
106
+
107
+ // NUMBERS PAGINATION
108
+ const NumbersPagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, style }) => {
109
+ return (
110
+ <View style={[styles.numbersContainer, style]}>
111
+ {Array.from({ length: totalSlides }).map((_, index) => {
112
+ const isCurrent = index === currentIndex;
113
+
114
+ return (
115
+ <Animated.View
116
+ key={index}
117
+ style={[
118
+ styles.numberCircle,
119
+ {
120
+ backgroundColor: isCurrent ? theme.colors.primary : 'transparent',
121
+ borderColor: theme.colors.border,
122
+ },
123
+ ]}
124
+ >
125
+ <Animated.Text
126
+ style={[
127
+ styles.numberText,
128
+ { color: isCurrent ? theme.colors.text.inverse : theme.colors.text.secondary },
129
+ ]}
130
+ >
131
+ {index + 1}
132
+ </Animated.Text>
133
+ </Animated.View>
134
+ );
135
+ })}
136
+ </View>
137
+ );
138
+ };
139
+
140
+ // FLOATING DOTS PAGINATION
141
+ const FloatingDotsPagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, style }) => {
142
+ return (
143
+ <View style={[styles.floatingContainer, style]}>
144
+ <LinearGradient
145
+ colors={[theme.colors.surface + 'CC', theme.colors.surface + 'CC']}
146
+ style={styles.floatingBackground}
147
+ >
148
+ <View style={styles.floatingDots}>
149
+ {Array.from({ length: totalSlides }).map((_, index) => (
150
+ <Animated.View
151
+ key={index}
152
+ style={[
153
+ styles.floatingDot,
154
+ {
155
+ width: index === currentIndex ? 28 : 10,
156
+ backgroundColor: index === currentIndex ? theme.colors.primary : theme.colors.border,
157
+ },
158
+ ]}
159
+ />
160
+ ))}
161
+ </View>
162
+ </LinearGradient>
163
+ </View>
164
+ );
165
+ };
166
+
167
+ // MAIN PAGINATION COMPONENT
168
+ export const Pagination: React.FC<PaginationProps> = ({ currentIndex, totalSlides, theme, config, style }) => {
169
+ const { style: navStyle, position } = config;
170
+
171
+ const containerStyle: ViewStyle = useMemo(() => {
172
+ const base: ViewStyle = {};
173
+
174
+ if (position === 'top') {
175
+ base.position = 'absolute';
176
+ base.top = 0;
177
+ base.left = 0;
178
+ base.right = 0;
179
+ base.paddingTop = 20;
180
+ } else if (position === 'bottom') {
181
+ base.position = 'absolute';
182
+ base.bottom = 0;
183
+ base.left = 0;
184
+ base.right = 0;
185
+ base.paddingBottom = 20;
186
+ }
187
+
188
+ return base;
189
+ }, [position]);
190
+
191
+ const renderPagination = () => {
192
+ switch (navStyle) {
193
+ case 'dots':
194
+ return <DotsPagination currentIndex={currentIndex} totalSlides={totalSlides} theme={theme} style={style} />;
195
+ case 'progress-bar':
196
+ return <ProgressBarPagination currentIndex={currentIndex} totalSlides={totalSlides} theme={theme} style={style} />;
197
+ case 'steps':
198
+ return <StepsPagination currentIndex={currentIndex} totalSlides={totalSlides} theme={theme} style={style} />;
199
+ case 'numbers':
200
+ return <NumbersPagination currentIndex={currentIndex} totalSlides={totalSlides} theme={theme} style={style} />;
201
+ case 'none':
202
+ return null;
203
+ default:
204
+ return <DotsPagination currentIndex={currentIndex} totalSlides={totalSlides} theme={theme} style={style} />;
205
+ }
206
+ };
207
+
208
+ if (navStyle === 'none') return null;
209
+
210
+ return <View style={containerStyle}>{renderPagination()}</View>;
211
+ };
212
+
213
+ const styles = StyleSheet.create({
214
+ // Dots
215
+ dotsContainer: {
216
+ flexDirection: 'row',
217
+ alignItems: 'center',
218
+ justifyContent: 'center',
219
+ gap: 8,
220
+ paddingVertical: 16,
221
+ },
222
+ dot: {
223
+ height: 8,
224
+ borderRadius: 4,
225
+ },
226
+
227
+ // Progress Bar
228
+ progressContainer: {
229
+ paddingHorizontal: 24,
230
+ paddingVertical: 16,
231
+ alignItems: 'center',
232
+ },
233
+ progressBackground: {
234
+ width: '100%',
235
+ height: 4,
236
+ borderRadius: 2,
237
+ overflow: 'hidden',
238
+ marginBottom: 8,
239
+ },
240
+ progressFill: {
241
+ height: '100%',
242
+ borderRadius: 2,
243
+ },
244
+ progressText: {
245
+ fontSize: 13,
246
+ fontWeight: '500',
247
+ },
248
+
249
+ // Steps
250
+ stepsContainer: {
251
+ flexDirection: 'row',
252
+ alignItems: 'center',
253
+ paddingHorizontal: 24,
254
+ paddingVertical: 16,
255
+ },
256
+ stepItem: {
257
+ flexDirection: 'row',
258
+ alignItems: 'center',
259
+ flex: 1,
260
+ },
261
+ stepCircle: {
262
+ width: 32,
263
+ height: 32,
264
+ borderRadius: 16,
265
+ borderWidth: 2,
266
+ alignItems: 'center',
267
+ justifyContent: 'center',
268
+ },
269
+ stepNumber: {
270
+ fontSize: 14,
271
+ fontWeight: '600',
272
+ },
273
+ stepCheckmark: {
274
+ width: 12,
275
+ height: 6,
276
+ borderBottomWidth: 2,
277
+ borderLeftWidth: 2,
278
+ borderColor: '#FFFFFF',
279
+ transform: [{ rotate: '-45deg' }, { translateY: -2 }],
280
+ },
281
+ stepLine: {
282
+ flex: 1,
283
+ height: 2,
284
+ minWidth: 8,
285
+ },
286
+
287
+ // Numbers
288
+ numbersContainer: {
289
+ flexDirection: 'row',
290
+ alignItems: 'center',
291
+ justifyContent: 'center',
292
+ gap: 16,
293
+ paddingVertical: 16,
294
+ },
295
+ numberCircle: {
296
+ width: 36,
297
+ height: 36,
298
+ borderRadius: 18,
299
+ borderWidth: 2,
300
+ alignItems: 'center',
301
+ justifyContent: 'center',
302
+ },
303
+ numberText: {
304
+ fontSize: 14,
305
+ fontWeight: '600',
306
+ },
307
+
308
+ // Floating
309
+ floatingContainer: {
310
+ position: 'absolute',
311
+ bottom: 32,
312
+ left: 0,
313
+ right: 0,
314
+ alignItems: 'center',
315
+ },
316
+ floatingBackground: {
317
+ paddingHorizontal: 20,
318
+ paddingVertical: 12,
319
+ borderRadius: 24,
320
+ shadowColor: '#000',
321
+ shadowOffset: { width: 0, height: 4 },
322
+ shadowOpacity: 0.15,
323
+ shadowRadius: 12,
324
+ elevation: 8,
325
+ },
326
+ floatingDots: {
327
+ flexDirection: 'row',
328
+ alignItems: 'center',
329
+ gap: 8,
330
+ },
331
+ floatingDot: {
332
+ height: 10,
333
+ borderRadius: 5,
334
+ },
335
+ });
336
+
337
+ export default Pagination;
@@ -0,0 +1,5 @@
1
+ export { Pagination } from './Pagination';
2
+ export { NavigationButton, NavigationButtons } from './NavigationButtons';
3
+ export { Onboarding, useOnboarding } from './Onboarding';
4
+
5
+ export { default } from './Onboarding';