@umituz/react-native-design-system 2.6.128 → 2.8.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/package.json +4 -2
- package/src/exports/onboarding.ts +6 -0
- package/src/index.ts +5 -0
- package/src/molecules/navigation/utils/AppNavigation.ts +53 -28
- package/src/molecules/splash/hooks/useSplashFlow.ts +33 -7
- package/src/onboarding/domain/entities/OnboardingOptions.ts +104 -0
- package/src/onboarding/domain/entities/OnboardingQuestion.ts +165 -0
- package/src/onboarding/domain/entities/OnboardingSlide.ts +152 -0
- package/src/onboarding/domain/entities/OnboardingUserData.ts +43 -0
- package/src/onboarding/hooks/useOnboardingFlow.ts +50 -0
- package/src/onboarding/index.ts +108 -0
- package/src/onboarding/infrastructure/hooks/__tests__/useOnboardingAnswers.test.ts +163 -0
- package/src/onboarding/infrastructure/hooks/__tests__/useOnboardingNavigation.test.ts +121 -0
- package/src/onboarding/infrastructure/hooks/useOnboardingAnswers.ts +69 -0
- package/src/onboarding/infrastructure/hooks/useOnboardingNavigation.ts +75 -0
- package/src/onboarding/infrastructure/services/SlideManager.ts +53 -0
- package/src/onboarding/infrastructure/services/ValidationManager.ts +127 -0
- package/src/onboarding/infrastructure/storage/OnboardingStore.ts +99 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreActions.ts +50 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreSelectors.ts +25 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreState.ts +22 -0
- package/src/onboarding/infrastructure/storage/__tests__/OnboardingStore.test.ts +85 -0
- package/src/onboarding/infrastructure/storage/actions/answerActions.ts +47 -0
- package/src/onboarding/infrastructure/storage/actions/completeAction.ts +45 -0
- package/src/onboarding/infrastructure/storage/actions/index.ts +22 -0
- package/src/onboarding/infrastructure/storage/actions/initializeAction.ts +40 -0
- package/src/onboarding/infrastructure/storage/actions/resetAction.ts +37 -0
- package/src/onboarding/infrastructure/storage/actions/skipAction.ts +46 -0
- package/src/onboarding/infrastructure/storage/actions/storageHelpers.ts +60 -0
- package/src/onboarding/infrastructure/utils/arrayUtils.ts +28 -0
- package/src/onboarding/infrastructure/utils/backgroundUtils.ts +38 -0
- package/src/onboarding/infrastructure/utils/layouts/collageLayout.ts +81 -0
- package/src/onboarding/infrastructure/utils/layouts/gridLayouts.ts +78 -0
- package/src/onboarding/infrastructure/utils/layouts/honeycombLayout.ts +36 -0
- package/src/onboarding/infrastructure/utils/layouts/index.ts +12 -0
- package/src/onboarding/infrastructure/utils/layouts/layoutTypes.ts +37 -0
- package/src/onboarding/infrastructure/utils/layouts/masonryLayout.ts +37 -0
- package/src/onboarding/infrastructure/utils/layouts/scatteredLayout.ts +34 -0
- package/src/onboarding/infrastructure/utils/layouts/screenDimensions.ts +11 -0
- package/src/onboarding/infrastructure/utils/layouts/tilesLayout.ts +34 -0
- package/src/onboarding/presentation/components/BackgroundImageCollage.tsx +90 -0
- package/src/onboarding/presentation/components/BackgroundVideo.tsx +24 -0
- package/src/onboarding/presentation/components/BaseSlide.tsx +47 -0
- package/src/onboarding/presentation/components/OnboardingBackground.tsx +91 -0
- package/src/onboarding/presentation/components/OnboardingFooter.tsx +151 -0
- package/src/onboarding/presentation/components/OnboardingHeader.tsx +92 -0
- package/src/onboarding/presentation/components/OnboardingResetSetting.tsx +70 -0
- package/src/onboarding/presentation/components/OnboardingScreenContent.tsx +146 -0
- package/src/onboarding/presentation/components/OnboardingSlide.tsx +124 -0
- package/src/onboarding/presentation/components/QuestionRenderer.tsx +60 -0
- package/src/onboarding/presentation/components/QuestionSlide.tsx +67 -0
- package/src/onboarding/presentation/components/QuestionSlideHeader.tsx +75 -0
- package/src/onboarding/presentation/components/questions/MultipleChoiceQuestion.tsx +74 -0
- package/src/onboarding/presentation/components/questions/QuestionOptionItem.tsx +115 -0
- package/src/onboarding/presentation/components/questions/RatingQuestion.tsx +66 -0
- package/src/onboarding/presentation/components/questions/SingleChoiceQuestion.tsx +117 -0
- package/src/onboarding/presentation/components/questions/TextInputQuestion.tsx +71 -0
- package/src/onboarding/presentation/hooks/__tests__/useOnboardingContainerStyle.test.ts +96 -0
- package/src/onboarding/presentation/hooks/useOnboardingContainerStyle.ts +37 -0
- package/src/onboarding/presentation/hooks/useOnboardingGestures.ts +45 -0
- package/src/onboarding/presentation/hooks/useOnboardingScreenHandlers.ts +114 -0
- package/src/onboarding/presentation/hooks/useOnboardingScreenState.ts +146 -0
- package/src/onboarding/presentation/providers/OnboardingProvider.tsx +51 -0
- package/src/onboarding/presentation/screens/OnboardingScreen.tsx +189 -0
- package/src/onboarding/presentation/types/OnboardingProps.ts +46 -0
- package/src/onboarding/presentation/types/OnboardingTheme.ts +27 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding User Data Entity
|
|
3
|
+
*
|
|
4
|
+
* Domain entity representing collected user data from onboarding
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* User data collected during onboarding
|
|
9
|
+
*/
|
|
10
|
+
export interface OnboardingUserData {
|
|
11
|
+
/**
|
|
12
|
+
* User's answers to questions
|
|
13
|
+
* Key: question ID, Value: answer
|
|
14
|
+
*/
|
|
15
|
+
answers: Record<string, any>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Timestamp when onboarding was completed
|
|
19
|
+
*/
|
|
20
|
+
completedAt?: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Was onboarding skipped?
|
|
24
|
+
*/
|
|
25
|
+
skipped?: boolean;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* User preferences derived from answers
|
|
29
|
+
*/
|
|
30
|
+
preferences?: Record<string, any>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* User profile data
|
|
34
|
+
*/
|
|
35
|
+
profile?: {
|
|
36
|
+
name?: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
age?: number;
|
|
39
|
+
gender?: string;
|
|
40
|
+
[key: string]: any;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Flow Hook
|
|
3
|
+
* Manages onboarding completion state with persistence
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
7
|
+
import { DeviceEventEmitter } from 'react-native';
|
|
8
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
9
|
+
|
|
10
|
+
const ONBOARDING_KEY = 'onboarding_complete';
|
|
11
|
+
|
|
12
|
+
export interface UseOnboardingFlowResult {
|
|
13
|
+
isOnboardingComplete: boolean;
|
|
14
|
+
completeOnboarding: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useOnboardingFlow = (): UseOnboardingFlowResult => {
|
|
18
|
+
const [isOnboardingComplete, setIsOnboardingComplete] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Load persisted state
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const loadPersistedState = async () => {
|
|
23
|
+
const value = await AsyncStorage.getItem(ONBOARDING_KEY);
|
|
24
|
+
setIsOnboardingComplete(value === 'true');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
loadPersistedState();
|
|
28
|
+
|
|
29
|
+
const subscription = DeviceEventEmitter.addListener(
|
|
30
|
+
'onboarding-complete',
|
|
31
|
+
() => {
|
|
32
|
+
setIsOnboardingComplete(true);
|
|
33
|
+
AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return () => subscription.remove();
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const completeOnboarding = useCallback(async () => {
|
|
41
|
+
await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
|
42
|
+
setIsOnboardingComplete(true);
|
|
43
|
+
DeviceEventEmitter.emit('onboarding-complete');
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
isOnboardingComplete,
|
|
48
|
+
completeOnboarding,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native Onboarding - Public API
|
|
3
|
+
*
|
|
4
|
+
* Generic onboarding flow for React Native apps with custom backgrounds,
|
|
5
|
+
* animations, and customizable slides. Follows SOLID, DRY, KISS principles.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Domain: Entities and interfaces (business logic)
|
|
9
|
+
* - Infrastructure: Storage and hooks (state management)
|
|
10
|
+
* - Presentation: Components and screens (UI)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { OnboardingScreen, OnboardingSlide } from '@umituz/react-native-onboarding';
|
|
14
|
+
*
|
|
15
|
+
* <OnboardingScreen
|
|
16
|
+
* slides={[
|
|
17
|
+
* {
|
|
18
|
+
* id: '1',
|
|
19
|
+
* title: 'Welcome',
|
|
20
|
+
* description: 'Welcome to the app',
|
|
21
|
+
* icon: '🎉',
|
|
22
|
+
* backgroundColor: '#3B82F6',
|
|
23
|
+
* },
|
|
24
|
+
* ]}
|
|
25
|
+
* onComplete={() => console.log('Completed')}
|
|
26
|
+
* />
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// DOMAIN LAYER - Entities and Interfaces
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export type { OnboardingSlide, SlideType } from "./domain/entities/OnboardingSlide";
|
|
34
|
+
export type { OnboardingOptions } from "./domain/entities/OnboardingOptions";
|
|
35
|
+
export type {
|
|
36
|
+
OnboardingQuestion,
|
|
37
|
+
QuestionType,
|
|
38
|
+
QuestionOption,
|
|
39
|
+
QuestionValidation,
|
|
40
|
+
} from "./domain/entities/OnboardingQuestion";
|
|
41
|
+
export type { OnboardingUserData } from "./domain/entities/OnboardingUserData";
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// INFRASTRUCTURE LAYER - Storage and Hooks
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
useOnboardingStore,
|
|
49
|
+
useOnboarding,
|
|
50
|
+
} from "./infrastructure/storage/OnboardingStore";
|
|
51
|
+
export type { OnboardingStoreState } from "./infrastructure/storage/OnboardingStoreState";
|
|
52
|
+
export type { OnboardingStoreActions } from "./infrastructure/storage/OnboardingStoreActions";
|
|
53
|
+
export type { OnboardingStoreSelectors } from "./infrastructure/storage/OnboardingStoreSelectors";
|
|
54
|
+
export {
|
|
55
|
+
useOnboardingNavigation,
|
|
56
|
+
type UseOnboardingNavigationReturn,
|
|
57
|
+
} from "./infrastructure/hooks/useOnboardingNavigation";
|
|
58
|
+
export {
|
|
59
|
+
useOnboardingContainerStyle,
|
|
60
|
+
type UseOnboardingContainerStyleProps,
|
|
61
|
+
type UseOnboardingContainerStyleReturn,
|
|
62
|
+
} from "./presentation/hooks/useOnboardingContainerStyle";
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// PRESENTATION LAYER - Components and Screens
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
export { OnboardingScreen, type OnboardingScreenProps } from "./presentation/screens/OnboardingScreen";
|
|
69
|
+
export { OnboardingHeader, type OnboardingHeaderProps } from "./presentation/components/OnboardingHeader";
|
|
70
|
+
export { OnboardingFooter, type OnboardingFooterProps } from "./presentation/components/OnboardingFooter";
|
|
71
|
+
export { OnboardingProvider, type OnboardingProviderProps, useOnboardingProvider } from "./presentation/providers/OnboardingProvider";
|
|
72
|
+
export { BackgroundImageCollage, type CollageLayout, type BackgroundImageCollageProps } from "./presentation/components/BackgroundImageCollage";
|
|
73
|
+
export type { OnboardingTheme, OnboardingColors } from "./presentation/types/OnboardingTheme";
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// UTILITIES - Helper functions
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
export { ensureArray, safeIncludes, safeFilter } from "./infrastructure/utils/arrayUtils";
|
|
80
|
+
export type { ImageLayoutItem, LayoutConfig } from "./infrastructure/utils/layouts";
|
|
81
|
+
|
|
82
|
+
// Export OnboardingSlide component
|
|
83
|
+
// Note: TypeScript doesn't allow exporting both a type and a value with the same name
|
|
84
|
+
// The type is exported above as OnboardingSlide
|
|
85
|
+
// The component is exported here with a different name to avoid conflict
|
|
86
|
+
import { OnboardingSlide as OnboardingSlideComponent } from "./presentation/components/OnboardingSlide";
|
|
87
|
+
export { OnboardingSlideComponent };
|
|
88
|
+
export type { OnboardingSlideProps } from "./presentation/components/OnboardingSlide";
|
|
89
|
+
|
|
90
|
+
// Export QuestionSlide component
|
|
91
|
+
export { QuestionSlide } from "./presentation/components/QuestionSlide";
|
|
92
|
+
export type { QuestionSlideProps } from "./presentation/components/QuestionSlide";
|
|
93
|
+
|
|
94
|
+
// Export question components
|
|
95
|
+
export { SingleChoiceQuestion } from "./presentation/components/questions/SingleChoiceQuestion";
|
|
96
|
+
export type { SingleChoiceQuestionProps } from "./presentation/components/questions/SingleChoiceQuestion";
|
|
97
|
+
export { MultipleChoiceQuestion } from "./presentation/components/questions/MultipleChoiceQuestion";
|
|
98
|
+
export type { MultipleChoiceQuestionProps } from "./presentation/components/questions/MultipleChoiceQuestion";
|
|
99
|
+
export { TextInputQuestion } from "./presentation/components/questions/TextInputQuestion";
|
|
100
|
+
export type { TextInputQuestionProps } from "./presentation/components/questions/TextInputQuestion";
|
|
101
|
+
export { RatingQuestion } from "./presentation/components/questions/RatingQuestion";
|
|
102
|
+
export type { RatingQuestionProps } from "./presentation/components/questions/RatingQuestion";
|
|
103
|
+
|
|
104
|
+
export { OnboardingResetSetting } from "./presentation/components/OnboardingResetSetting";
|
|
105
|
+
export type { OnboardingResetSettingProps } from "./presentation/components/OnboardingResetSetting";
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
export { useOnboardingFlow, type UseOnboardingFlowResult } from './hooks/useOnboardingFlow';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OnboardingAnswers Hook Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook, act } from '@testing-library/react-native';
|
|
6
|
+
import { useOnboardingAnswers } from '../useOnboardingAnswers';
|
|
7
|
+
import { useOnboardingStore } from '../../storage/OnboardingStore';
|
|
8
|
+
|
|
9
|
+
// Mock the store
|
|
10
|
+
jest.mock('../../storage/OnboardingStore', () => ({
|
|
11
|
+
useOnboardingStore: jest.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockUseOnboardingStore = useOnboardingStore as jest.MockedFunction<typeof useOnboardingStore>;
|
|
15
|
+
|
|
16
|
+
describe('useOnboardingAnswers', () => {
|
|
17
|
+
const mockGetAnswer = jest.fn();
|
|
18
|
+
const mockSaveAnswer = jest.fn();
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
mockUseOnboardingStore.mockReturnValue({
|
|
23
|
+
getAnswer: mockGetAnswer,
|
|
24
|
+
saveAnswer: mockSaveAnswer,
|
|
25
|
+
} as any);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should initialize with undefined answer', () => {
|
|
29
|
+
const mockSlide = {
|
|
30
|
+
id: '1',
|
|
31
|
+
type: 'question',
|
|
32
|
+
question: {
|
|
33
|
+
id: 'q1',
|
|
34
|
+
type: 'single',
|
|
35
|
+
text: 'Test question',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
40
|
+
|
|
41
|
+
expect(result.current.currentAnswer).toBeUndefined();
|
|
42
|
+
expect(mockGetAnswer).toHaveBeenCalledWith('q1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should load saved answer for slide', () => {
|
|
46
|
+
const mockSlide = {
|
|
47
|
+
id: '1',
|
|
48
|
+
type: 'question',
|
|
49
|
+
question: {
|
|
50
|
+
id: 'q1',
|
|
51
|
+
type: 'single',
|
|
52
|
+
text: 'Test question',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
mockGetAnswer.mockReturnValue('option1');
|
|
57
|
+
|
|
58
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
59
|
+
|
|
60
|
+
expect(result.current.currentAnswer).toBe('option1');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should use default value when no saved answer', () => {
|
|
64
|
+
const mockSlide = {
|
|
65
|
+
id: '1',
|
|
66
|
+
type: 'question',
|
|
67
|
+
question: {
|
|
68
|
+
id: 'q1',
|
|
69
|
+
type: 'single',
|
|
70
|
+
text: 'Test question',
|
|
71
|
+
defaultValue: 'default',
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
mockGetAnswer.mockReturnValue(undefined);
|
|
76
|
+
|
|
77
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
78
|
+
|
|
79
|
+
expect(result.current.currentAnswer).toBe('default');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should set current answer', () => {
|
|
83
|
+
const mockSlide = {
|
|
84
|
+
id: '1',
|
|
85
|
+
type: 'question',
|
|
86
|
+
question: {
|
|
87
|
+
id: 'q1',
|
|
88
|
+
type: 'single',
|
|
89
|
+
text: 'Test question',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.setCurrentAnswer('newAnswer');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.current.currentAnswer).toBe('newAnswer');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should save answer for slide', async () => {
|
|
103
|
+
const mockSlide = {
|
|
104
|
+
id: '1',
|
|
105
|
+
type: 'question',
|
|
106
|
+
question: {
|
|
107
|
+
id: 'q1',
|
|
108
|
+
type: 'single',
|
|
109
|
+
text: 'Test question',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
mockSaveAnswer.mockResolvedValue(undefined);
|
|
114
|
+
|
|
115
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
result.current.setCurrentAnswer('answer1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(mockSaveAnswer).toHaveBeenCalledWith('q1', 'answer1');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should not save answer for non-question slide', async () => {
|
|
129
|
+
const mockSlide = {
|
|
130
|
+
id: '1',
|
|
131
|
+
type: 'welcome',
|
|
132
|
+
title: 'Welcome',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mockSaveAnswer).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should not save undefined answer', async () => {
|
|
145
|
+
const mockSlide = {
|
|
146
|
+
id: '1',
|
|
147
|
+
type: 'question',
|
|
148
|
+
question: {
|
|
149
|
+
id: 'q1',
|
|
150
|
+
type: 'single',
|
|
151
|
+
text: 'Test question',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(mockSaveAnswer).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OnboardingNavigation Hook Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook, act } from '@testing-library/react-native';
|
|
6
|
+
import { useOnboardingNavigation } from '../useOnboardingNavigation';
|
|
7
|
+
|
|
8
|
+
describe('useOnboardingNavigation', () => {
|
|
9
|
+
const mockOnComplete = jest.fn();
|
|
10
|
+
const mockOnSkip = jest.fn();
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should initialize with first slide', () => {
|
|
17
|
+
const { result } = renderHook(() =>
|
|
18
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(result.current.currentIndex).toBe(0);
|
|
22
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
23
|
+
expect(result.current.isLastSlide).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should navigate to next slide', () => {
|
|
27
|
+
const { result } = renderHook(() =>
|
|
28
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
act(() => {
|
|
32
|
+
result.current.goToNext();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.current.currentIndex).toBe(1);
|
|
36
|
+
expect(result.current.isFirstSlide).toBe(false);
|
|
37
|
+
expect(result.current.isLastSlide).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should navigate to previous slide', () => {
|
|
41
|
+
const { result } = renderHook(() =>
|
|
42
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.goToNext();
|
|
47
|
+
});
|
|
48
|
+
act(() => {
|
|
49
|
+
result.current.goToPrevious();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.current.currentIndex).toBe(0);
|
|
53
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle last slide correctly', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
result.current.goToNext();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.current.currentIndex).toBe(1);
|
|
66
|
+
expect(result.current.isLastSlide).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not go beyond last slide', () => {
|
|
70
|
+
const { result } = renderHook(() =>
|
|
71
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
act(() => {
|
|
75
|
+
result.current.goToNext();
|
|
76
|
+
});
|
|
77
|
+
act(() => {
|
|
78
|
+
result.current.goToNext();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.current.currentIndex).toBe(1);
|
|
82
|
+
expect(result.current.isLastSlide).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should not go before first slide', () => {
|
|
86
|
+
const { result } = renderHook(() =>
|
|
87
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current.goToPrevious();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current.currentIndex).toBe(0);
|
|
95
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should call onComplete when completing', async () => {
|
|
99
|
+
const { result } = renderHook(() =>
|
|
100
|
+
useOnboardingNavigation(1, mockOnComplete, mockOnSkip)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await act(async () => {
|
|
104
|
+
await result.current.complete();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mockOnComplete).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should call onSkip when skipping', async () => {
|
|
111
|
+
const { result } = renderHook(() =>
|
|
112
|
+
useOnboardingNavigation(1, mockOnComplete, mockOnSkip)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await result.current.skip();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(mockOnSkip).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOnboardingAnswers Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages answer state and operations for onboarding questions
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from "react";
|
|
9
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
10
|
+
import { useOnboarding } from "../storage/OnboardingStore";
|
|
11
|
+
|
|
12
|
+
export interface UseOnboardingAnswersReturn {
|
|
13
|
+
currentAnswer: any;
|
|
14
|
+
setCurrentAnswer: (answer: any) => void;
|
|
15
|
+
loadAnswerForSlide: (slide: OnboardingSlide | undefined) => void;
|
|
16
|
+
saveCurrentAnswer: (slide: OnboardingSlide | undefined) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook for managing onboarding question answers
|
|
21
|
+
* @param currentSlide - The current slide being displayed
|
|
22
|
+
* @returns Answer state and operations
|
|
23
|
+
*/
|
|
24
|
+
export function useOnboardingAnswers(
|
|
25
|
+
currentSlide: OnboardingSlide | undefined,
|
|
26
|
+
): UseOnboardingAnswersReturn {
|
|
27
|
+
const onboardingStore = useOnboarding();
|
|
28
|
+
const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load answer for a specific slide
|
|
32
|
+
*/
|
|
33
|
+
const loadAnswerForSlide = useCallback(
|
|
34
|
+
(slide: OnboardingSlide | undefined) => {
|
|
35
|
+
if (slide?.question) {
|
|
36
|
+
const savedAnswer = onboardingStore.getAnswer(slide.question.id);
|
|
37
|
+
setCurrentAnswer(savedAnswer ?? slide.question.defaultValue);
|
|
38
|
+
} else {
|
|
39
|
+
setCurrentAnswer(undefined);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[onboardingStore],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Save current answer for a slide
|
|
47
|
+
*/
|
|
48
|
+
const saveCurrentAnswer = useCallback(
|
|
49
|
+
async (slide: OnboardingSlide | undefined) => {
|
|
50
|
+
if (slide?.question && currentAnswer !== undefined) {
|
|
51
|
+
await onboardingStore.saveAnswer(slide.question.id, currentAnswer);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[currentAnswer, onboardingStore],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Load answer when slide changes
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
loadAnswerForSlide(currentSlide);
|
|
60
|
+
}, [currentSlide, loadAnswerForSlide]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
currentAnswer,
|
|
64
|
+
setCurrentAnswer,
|
|
65
|
+
loadAnswerForSlide,
|
|
66
|
+
saveCurrentAnswer,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOnboardingNavigation Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages navigation state and callbacks for onboarding flow
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import { DeviceEventEmitter } from "react-native";
|
|
9
|
+
|
|
10
|
+
export interface UseOnboardingNavigationReturn {
|
|
11
|
+
currentIndex: number;
|
|
12
|
+
goToNext: () => void;
|
|
13
|
+
goToPrevious: () => void;
|
|
14
|
+
complete: () => Promise<void>;
|
|
15
|
+
skip: () => Promise<void>;
|
|
16
|
+
isLastSlide: boolean;
|
|
17
|
+
isFirstSlide: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hook for managing onboarding navigation
|
|
22
|
+
*
|
|
23
|
+
* @param totalSlides - Total number of slides
|
|
24
|
+
* @param onComplete - Callback when onboarding completes
|
|
25
|
+
* @param onSkip - Callback when onboarding is skipped
|
|
26
|
+
* @returns Navigation state and handlers
|
|
27
|
+
*/
|
|
28
|
+
export const useOnboardingNavigation = (
|
|
29
|
+
totalSlides: number,
|
|
30
|
+
onComplete?: () => void | Promise<void>,
|
|
31
|
+
onSkip?: () => void | Promise<void>,
|
|
32
|
+
): UseOnboardingNavigationReturn => {
|
|
33
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
34
|
+
|
|
35
|
+
const goToNext = useCallback(() => {
|
|
36
|
+
if (currentIndex < totalSlides - 1) {
|
|
37
|
+
setCurrentIndex(currentIndex + 1);
|
|
38
|
+
}
|
|
39
|
+
}, [currentIndex, totalSlides]);
|
|
40
|
+
|
|
41
|
+
const goToPrevious = useCallback(() => {
|
|
42
|
+
if (currentIndex > 0) {
|
|
43
|
+
setCurrentIndex(currentIndex - 1);
|
|
44
|
+
}
|
|
45
|
+
}, [currentIndex]);
|
|
46
|
+
|
|
47
|
+
const complete = useCallback(async () => {
|
|
48
|
+
if (onComplete) {
|
|
49
|
+
await onComplete();
|
|
50
|
+
}
|
|
51
|
+
// Emit event for app-level handling
|
|
52
|
+
|
|
53
|
+
if (__DEV__) console.log("[useOnboardingNavigation] Emitting onboarding-complete event");
|
|
54
|
+
DeviceEventEmitter.emit("onboarding-complete");
|
|
55
|
+
}, [onComplete]);
|
|
56
|
+
|
|
57
|
+
const skip = useCallback(async () => {
|
|
58
|
+
if (onSkip) {
|
|
59
|
+
await onSkip();
|
|
60
|
+
}
|
|
61
|
+
// Emit event for app-level handling
|
|
62
|
+
DeviceEventEmitter.emit("onboarding-complete");
|
|
63
|
+
}, [onSkip]);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
currentIndex,
|
|
67
|
+
goToNext,
|
|
68
|
+
goToPrevious,
|
|
69
|
+
complete,
|
|
70
|
+
skip,
|
|
71
|
+
isLastSlide: currentIndex === totalSlides - 1,
|
|
72
|
+
isFirstSlide: currentIndex === 0,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Slide Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for filtering and processing onboarding slides
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
9
|
+
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SlideManager
|
|
13
|
+
*/
|
|
14
|
+
export class SlideManager {
|
|
15
|
+
/**
|
|
16
|
+
* Filter slides based on skipIf conditions
|
|
17
|
+
* @param slides - All available slides
|
|
18
|
+
* @param userData - User's onboarding data including answers
|
|
19
|
+
* @returns Filtered slides that should be shown
|
|
20
|
+
*/
|
|
21
|
+
static filterSlides(
|
|
22
|
+
slides: OnboardingSlide[] | undefined,
|
|
23
|
+
userData: OnboardingUserData,
|
|
24
|
+
): OnboardingSlide[] {
|
|
25
|
+
// Safety check: return empty array if slides is undefined or not an array
|
|
26
|
+
if (!slides || !Array.isArray(slides)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return slides.filter((slide) => {
|
|
30
|
+
if (slide.skipIf) {
|
|
31
|
+
return !slide.skipIf(userData.answers);
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get slide at specific index
|
|
39
|
+
* @param slides - Filtered slides array
|
|
40
|
+
* @param index - Slide index
|
|
41
|
+
* @returns Slide at index or undefined
|
|
42
|
+
*/
|
|
43
|
+
static getSlideAtIndex(
|
|
44
|
+
slides: OnboardingSlide[],
|
|
45
|
+
index: number,
|
|
46
|
+
): OnboardingSlide | undefined {
|
|
47
|
+
if (index < 0 || index >= slides.length) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return slides[index];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|