@umituz/react-native-settings 5.2.15 → 5.2.17
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 +1 -1
- package/src/domains/appearance/presentation/components/ColorPicker.tsx +4 -6
- package/src/domains/faqs/presentation/components/FAQItem.tsx +2 -2
- package/src/domains/faqs/presentation/hooks/useFAQSearch.ts +17 -5
- package/src/domains/faqs/presentation/screens/FAQScreen.tsx +44 -25
- package/src/domains/gamification/components/GamificationScreen/AchievementsList.tsx +4 -4
- package/src/domains/gamification/hooks/useGamification.ts +1 -1
- package/src/domains/legal/presentation/components/LegalLinks.tsx +4 -4
- package/src/domains/localization/infrastructure/config/I18nInitializer.ts +2 -2
- package/src/domains/localization/infrastructure/hooks/useLanguageSelection.ts +16 -4
- package/src/domains/localization/infrastructure/storage/AsyncStorageWrapper.ts +15 -5
- package/src/domains/localization/infrastructure/storage/LanguageSwitcher.ts +21 -17
- package/src/domains/localization/infrastructure/storage/LocalizationStore.ts +55 -42
- package/src/domains/localization/presentation/screens/LanguageSelectionScreen.tsx +14 -4
- package/src/domains/notifications/infrastructure/services/NotificationScheduler.ts +77 -57
- package/src/domains/notifications/infrastructure/services/NotificationService.ts +1 -0
- package/src/domains/notifications/infrastructure/utils/triggerBuilder.ts +3 -1
- package/src/domains/notifications/quietHours/infrastructure/hooks/useQuietHoursActions.ts +19 -10
- package/src/domains/notifications/reminders/presentation/screens/ReminderListScreen.tsx +9 -0
- package/src/domains/rating/application/services/RatingService.ts +5 -5
- package/src/domains/rating/infrastructure/storage/RatingStorage.ts +10 -10
- package/src/domains/rating/presentation/components/StarRating.tsx +6 -1
- package/src/domains/video-tutorials/presentation/screens/VideoTutorialsScreen.tsx +8 -0
- package/src/infrastructure/repositories/SettingsRepository.ts +2 -1
- package/src/infrastructure/utils/async/retryAndTimeout.ts +10 -2
- package/src/infrastructure/utils/constants/textLimits.ts +23 -0
- package/src/infrastructure/utils/sanitizers.ts +4 -2
- package/src/infrastructure/utils/styleTokens.ts +13 -0
- package/src/infrastructure/utils/validation/formValidators.ts +3 -2
- package/src/presentation/components/ErrorBoundary/index.ts +3 -0
- package/src/presentation/components/ErrorBoundary/withErrorBoundary.tsx +45 -0
- package/src/presentation/components/SettingsItemCard.tsx +2 -1
- package/src/presentation/navigation/hooks/useNavigationHandlers.ts +33 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-settings",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.17",
|
|
4
4
|
"description": "Complete settings hub for React Native apps - consolidated package with settings, localization, about, legal, appearance, feedback, FAQs, rating, and gamification",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AtomicText,
|
|
13
13
|
useAppDesignTokens,
|
|
14
14
|
} from "@umituz/react-native-design-system";
|
|
15
|
+
import { isDev } from "../../../../utils/devUtils";
|
|
15
16
|
|
|
16
17
|
interface ColorPickerProps {
|
|
17
18
|
label: string;
|
|
@@ -31,9 +32,6 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
31
32
|
// Memoize styles to prevent unnecessary re-creation
|
|
32
33
|
const styles = useMemo(() => getStyles(tokens), [tokens]);
|
|
33
34
|
|
|
34
|
-
// Memoize colors array to prevent unnecessary re-renders
|
|
35
|
-
const colorsMemo = useMemo(() => colors, [colors]);
|
|
36
|
-
|
|
37
35
|
// Stable callback for color change to prevent infinite re-renders
|
|
38
36
|
const handleColorChange = useCallback((color: string) => {
|
|
39
37
|
try {
|
|
@@ -41,7 +39,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
41
39
|
if (value === color) return;
|
|
42
40
|
onValueChange(color);
|
|
43
41
|
} catch (error) {
|
|
44
|
-
if (
|
|
42
|
+
if (isDev()) {
|
|
45
43
|
console.error('[ColorPicker] Failed to change color:', error);
|
|
46
44
|
}
|
|
47
45
|
// Optionally: Show user feedback about the error
|
|
@@ -50,7 +48,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
50
48
|
|
|
51
49
|
// Memoize color options to prevent unnecessary re-renders
|
|
52
50
|
const colorOptions = useMemo(() => {
|
|
53
|
-
return
|
|
51
|
+
return colors.map((color) => {
|
|
54
52
|
const isSelected = value === color;
|
|
55
53
|
|
|
56
54
|
return (
|
|
@@ -74,7 +72,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
74
72
|
</TouchableOpacity>
|
|
75
73
|
);
|
|
76
74
|
});
|
|
77
|
-
}, [
|
|
75
|
+
}, [colors, value, handleColorChange, styles, tokens.colors.textInverse]);
|
|
78
76
|
|
|
79
77
|
return (
|
|
80
78
|
<View style={styles.container}>
|
|
@@ -26,7 +26,7 @@ export interface FAQItemProps {
|
|
|
26
26
|
styles?: FAQItemStyles;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export const FAQItemComponent: React.FC<FAQItemProps> = ({
|
|
29
|
+
export const FAQItemComponent: React.FC<FAQItemProps> = React.memo(({
|
|
30
30
|
item,
|
|
31
31
|
isExpanded,
|
|
32
32
|
onToggle,
|
|
@@ -115,4 +115,4 @@ export const FAQItemComponent: React.FC<FAQItemProps> = ({
|
|
|
115
115
|
)}
|
|
116
116
|
</View>
|
|
117
117
|
);
|
|
118
|
-
};
|
|
118
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAQ Search Hook
|
|
3
|
-
* Handles FAQ search state and filtering
|
|
3
|
+
* Handles FAQ search state and filtering with debounce
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useMemo } from 'react';
|
|
6
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
7
7
|
import { FAQCategory } from '../../domain/entities/FAQEntity';
|
|
8
8
|
import { FAQSearchService } from '../../domain/services/FAQSearchService';
|
|
9
9
|
|
|
10
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
11
|
+
|
|
10
12
|
export interface UseFAQSearchResult {
|
|
11
13
|
searchQuery: string;
|
|
12
14
|
setSearchQuery: (query: string) => void;
|
|
@@ -16,13 +18,23 @@ export interface UseFAQSearchResult {
|
|
|
16
18
|
|
|
17
19
|
export function useFAQSearch(categories: FAQCategory[]): UseFAQSearchResult {
|
|
18
20
|
const [searchQuery, setSearchQuery] = useState('');
|
|
21
|
+
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
22
|
+
|
|
23
|
+
// Debounce search query
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
setDebouncedQuery(searchQuery);
|
|
27
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
28
|
+
|
|
29
|
+
return () => clearTimeout(timer);
|
|
30
|
+
}, [searchQuery]);
|
|
19
31
|
|
|
20
32
|
const filteredCategories = useMemo(
|
|
21
|
-
() => FAQSearchService.searchCategories(
|
|
22
|
-
[
|
|
33
|
+
() => FAQSearchService.searchCategories(debouncedQuery, categories),
|
|
34
|
+
[debouncedQuery, categories]
|
|
23
35
|
);
|
|
24
36
|
|
|
25
|
-
const hasResults = filteredCategories.length > 0 || !
|
|
37
|
+
const hasResults = filteredCategories.length > 0 || !debouncedQuery.trim();
|
|
26
38
|
|
|
27
39
|
return {
|
|
28
40
|
searchQuery,
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Uses design system tokens for theming
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import React, { useMemo } from 'react';
|
|
8
|
-
import { View,
|
|
7
|
+
import React, { useMemo, useCallback } from 'react';
|
|
8
|
+
import { View, FlatList, StyleSheet, ViewStyle, TextStyle, useWindowDimensions } from 'react-native';
|
|
9
9
|
import { useAppDesignTokens, ScreenLayout, getContentMaxWidth, NavigationHeader, useAppNavigation } from '@umituz/react-native-design-system';
|
|
10
10
|
import { FAQCategory } from '../../domain/entities/FAQEntity';
|
|
11
11
|
import { useFAQSearch } from '../hooks/useFAQSearch';
|
|
@@ -83,7 +83,29 @@ export const FAQScreen: React.FC<FAQScreenProps> = ({
|
|
|
83
83
|
/>
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
-
const
|
|
86
|
+
const renderCategory = useCallback(({ item }: { item: FAQCategory }) => (
|
|
87
|
+
<FAQCategoryComponent
|
|
88
|
+
category={item}
|
|
89
|
+
isExpanded={isExpanded}
|
|
90
|
+
onToggleItem={toggleExpansion}
|
|
91
|
+
styles={customStyles?.category}
|
|
92
|
+
/>
|
|
93
|
+
), [isExpanded, toggleExpansion, customStyles?.category]);
|
|
94
|
+
|
|
95
|
+
const keyExtractor = useCallback((item: FAQCategory) => item.id, []);
|
|
96
|
+
|
|
97
|
+
const renderListHeader = useCallback(() => (
|
|
98
|
+
<View style={[styles.header, customStyles?.header]}>
|
|
99
|
+
<FAQSearchBar
|
|
100
|
+
value={searchQuery}
|
|
101
|
+
onChangeText={setSearchQuery}
|
|
102
|
+
placeholder={searchPlaceholder}
|
|
103
|
+
styles={customStyles?.searchBar}
|
|
104
|
+
/>
|
|
105
|
+
</View>
|
|
106
|
+
), [searchQuery, setSearchQuery, searchPlaceholder, customStyles?.searchBar, customStyles?.header, styles.header]);
|
|
107
|
+
|
|
108
|
+
const renderListEmpty = useCallback(() => {
|
|
87
109
|
if (searchQuery && !hasResults) {
|
|
88
110
|
return (
|
|
89
111
|
<FAQEmptyState
|
|
@@ -93,34 +115,31 @@ export const FAQScreen: React.FC<FAQScreenProps> = ({
|
|
|
93
115
|
/>
|
|
94
116
|
);
|
|
95
117
|
}
|
|
118
|
+
return null;
|
|
119
|
+
}, [searchQuery, hasResults, emptySearchTitle, emptySearchMessage, customStyles?.emptyState]);
|
|
96
120
|
|
|
121
|
+
const renderListFooter = useCallback(() => (
|
|
122
|
+
<View style={{ height: tokens.spacing.xl * 2 }} />
|
|
123
|
+
), [tokens.spacing.xl]);
|
|
124
|
+
|
|
125
|
+
const renderContent = () => {
|
|
97
126
|
return (
|
|
98
127
|
<View style={{ flex: 1 }}>
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</View>
|
|
107
|
-
|
|
108
|
-
<ScrollView
|
|
128
|
+
<FlatList
|
|
129
|
+
data={filteredCategories}
|
|
130
|
+
renderItem={renderCategory}
|
|
131
|
+
keyExtractor={keyExtractor}
|
|
132
|
+
ListHeaderComponent={renderListHeader}
|
|
133
|
+
ListEmptyComponent={renderListEmpty}
|
|
134
|
+
ListFooterComponent={renderListFooter}
|
|
109
135
|
style={[styles.content, customStyles?.content]}
|
|
110
136
|
contentContainerStyle={{ paddingVertical: tokens.spacing.md }}
|
|
111
137
|
showsVerticalScrollIndicator={false}
|
|
112
|
-
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
isExpanded={isExpanded}
|
|
118
|
-
onToggleItem={toggleExpansion}
|
|
119
|
-
styles={customStyles?.category}
|
|
120
|
-
/>
|
|
121
|
-
))}
|
|
122
|
-
<View style={{ height: tokens.spacing.xl * 2 }} />
|
|
123
|
-
</ScrollView>
|
|
138
|
+
initialNumToRender={5}
|
|
139
|
+
maxToRenderPerBatch={5}
|
|
140
|
+
windowSize={10}
|
|
141
|
+
removeClippedSubviews={true}
|
|
142
|
+
/>
|
|
124
143
|
</View>
|
|
125
144
|
);
|
|
126
145
|
};
|
|
@@ -67,9 +67,9 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({
|
|
|
67
67
|
) : (
|
|
68
68
|
<>
|
|
69
69
|
{/* Unlocked achievements first */}
|
|
70
|
-
{unlocked.map((achievement
|
|
70
|
+
{unlocked.map((achievement) => (
|
|
71
71
|
<AchievementItem
|
|
72
|
-
key={`unlocked-${achievement.title}
|
|
72
|
+
key={`achievement-unlocked-${achievement.id || achievement.title}`}
|
|
73
73
|
{...achievement}
|
|
74
74
|
accentColor={accentColor}
|
|
75
75
|
backgroundColor={cardBackgroundColor}
|
|
@@ -79,9 +79,9 @@ export const AchievementsList: React.FC<AchievementsListProps> = ({
|
|
|
79
79
|
))}
|
|
80
80
|
|
|
81
81
|
{/* Locked achievements */}
|
|
82
|
-
{locked.map((achievement
|
|
82
|
+
{locked.map((achievement) => (
|
|
83
83
|
<AchievementItem
|
|
84
|
-
key={`locked-${achievement.title}
|
|
84
|
+
key={`achievement-locked-${achievement.id || achievement.title}`}
|
|
85
85
|
{...achievement}
|
|
86
86
|
accentColor={accentColor}
|
|
87
87
|
backgroundColor={cardBackgroundColor}
|
|
@@ -35,7 +35,7 @@ export const useGamification = (
|
|
|
35
35
|
if (config && !store.isInitialized) {
|
|
36
36
|
store.initialize(config);
|
|
37
37
|
}
|
|
38
|
-
}, [config, store.isInitialized]);
|
|
38
|
+
}, [config, store.isInitialized, store.initialize]);
|
|
39
39
|
|
|
40
40
|
// Calculate level from config
|
|
41
41
|
const level = useMemo((): LevelState => {
|
|
@@ -75,10 +75,10 @@ export const LegalLinks: React.FC<LegalLinksProps> = React.memo(
|
|
|
75
75
|
}
|
|
76
76
|
}, [onTermsPress, termsOfServiceUrl]);
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
const showPrivacy =
|
|
80
|
-
const showTerms =
|
|
81
|
-
const showSeparator =
|
|
78
|
+
// Direct boolean calculations - no need for useMemo overhead
|
|
79
|
+
const showPrivacy = !!(onPrivacyPress || privacyPolicyUrl);
|
|
80
|
+
const showTerms = !!(onTermsPress || termsOfServiceUrl);
|
|
81
|
+
const showSeparator = showPrivacy && showTerms;
|
|
82
82
|
|
|
83
83
|
return (
|
|
84
84
|
<View style={[styles.container, style]}>
|
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Language Selection Hook
|
|
3
|
-
* Manages language selection state and filtering
|
|
3
|
+
* Manages language selection state and filtering with debounce
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useMemo } from 'react';
|
|
6
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
7
7
|
import { useLocalization } from './useLocalization';
|
|
8
8
|
import { searchLanguages } from '../config/LanguageQuery';
|
|
9
9
|
import { devError } from '../../../../utils/devUtils';
|
|
10
10
|
|
|
11
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
12
|
+
|
|
11
13
|
export const useLanguageSelection = () => {
|
|
12
14
|
const { currentLanguage, setLanguage } = useLocalization();
|
|
13
15
|
const [searchQuery, setSearchQuery] = useState('');
|
|
16
|
+
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
14
17
|
const [selectedCode, setSelectedCode] = useState(currentLanguage);
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
// Debounce search query
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
setDebouncedQuery(searchQuery);
|
|
23
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
24
|
+
|
|
25
|
+
return () => clearTimeout(timer);
|
|
18
26
|
}, [searchQuery]);
|
|
19
27
|
|
|
28
|
+
const filteredLanguages = useMemo(() => {
|
|
29
|
+
return searchLanguages(debouncedQuery);
|
|
30
|
+
}, [debouncedQuery]);
|
|
31
|
+
|
|
20
32
|
const handleLanguageSelect = async (code: string, onComplete?: () => void) => {
|
|
21
33
|
try {
|
|
22
34
|
setSelectedCode(code);
|
|
@@ -11,14 +11,24 @@ export const STORAGE_KEYS = {
|
|
|
11
11
|
|
|
12
12
|
export const StorageWrapper = {
|
|
13
13
|
async getString(key: string, defaultValue: string): Promise<string> {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
try {
|
|
15
|
+
const result = await storageRepository.getString(key, defaultValue);
|
|
16
|
+
if (result.success && result.data !== null) {
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
|
19
|
+
return defaultValue;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error('[StorageWrapper] Failed to get string:', key, error);
|
|
22
|
+
return defaultValue;
|
|
17
23
|
}
|
|
18
|
-
return defaultValue;
|
|
19
24
|
},
|
|
20
25
|
|
|
21
26
|
async setString(key: string, value: string): Promise<void> {
|
|
22
|
-
|
|
27
|
+
try {
|
|
28
|
+
await storageRepository.setString(key, value);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('[StorageWrapper] Failed to set string:', key, error);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
23
33
|
},
|
|
24
34
|
};
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* - Persistence
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
declare const __DEV__: boolean;
|
|
10
|
-
|
|
11
9
|
import { storageRepository } from '@umituz/react-native-design-system';
|
|
12
10
|
import i18n from '../config/i18n';
|
|
13
11
|
import { languageRepository } from '../repository/LanguageRepository';
|
|
12
|
+
import { isDev } from '../../../../utils/devUtils';
|
|
14
13
|
|
|
15
14
|
const LANGUAGE_STORAGE_KEY = '@localization:language';
|
|
16
15
|
|
|
@@ -22,27 +21,32 @@ export class LanguageSwitcher {
|
|
|
22
21
|
languageCode: string;
|
|
23
22
|
isRTL: boolean;
|
|
24
23
|
}> {
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
try {
|
|
25
|
+
if (isDev()) {
|
|
26
|
+
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const language = languageRepository.getLanguageByCode(languageCode);
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (isDev()) {
|
|
31
|
+
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
await i18n.changeLanguage(languageCode);
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
if (isDev()) {
|
|
36
|
+
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
await storageRepository.setString(LANGUAGE_STORAGE_KEY, languageCode);
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if (isDev()) {
|
|
41
|
+
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
return {
|
|
44
|
+
languageCode,
|
|
45
|
+
isRTL: language?.isRTL ?? false,
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('[LanguageSwitcher] Failed to switch language:', languageCode, error);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
}
|
|
@@ -1,58 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Localization Store
|
|
3
|
-
*
|
|
2
|
+
* Localization Store
|
|
3
|
+
* Manages localization state using createStore pattern for consistency
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { createStore, storageService } from "@umituz/react-native-design-system";
|
|
7
7
|
import type { LocalizationState, LocalizationActions, LocalizationGetters } from "./types/LocalizationState";
|
|
8
8
|
import { languageRepository } from "../repository/LanguageRepository";
|
|
9
9
|
import { InitializationManager, LanguageSwitchManager, localizationGetters } from "./localizationStoreUtils";
|
|
10
10
|
|
|
11
|
-
type LocalizationStoreType = LocalizationState & LocalizationActions & LocalizationGetters;
|
|
12
|
-
|
|
13
11
|
// Instance-level managers
|
|
14
12
|
const initManager = new InitializationManager();
|
|
15
13
|
const switchManager = new LanguageSwitchManager();
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
// State
|
|
15
|
+
const DEFAULT_STATE: LocalizationState = {
|
|
19
16
|
currentLanguage: "en-US",
|
|
20
17
|
isRTL: false,
|
|
21
18
|
isInitialized: false,
|
|
22
19
|
supportedLanguages: languageRepository.getLanguages(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type LocalizationStoreActions = LocalizationActions & LocalizationGetters;
|
|
23
|
+
|
|
24
|
+
export const useLocalizationStore = createStore<LocalizationState, LocalizationStoreActions>({
|
|
25
|
+
name: "localization-storage",
|
|
26
|
+
initialState: DEFAULT_STATE,
|
|
27
|
+
persist: true,
|
|
28
|
+
storage: storageService,
|
|
29
|
+
version: 1,
|
|
30
|
+
partialize: (state) => ({
|
|
31
|
+
currentLanguage: state.currentLanguage,
|
|
32
|
+
isRTL: state.isRTL,
|
|
33
|
+
isInitialized: false, // Don't persist initialization state
|
|
34
|
+
supportedLanguages: state.supportedLanguages,
|
|
35
|
+
}),
|
|
36
|
+
actions: (set, get) => ({
|
|
37
|
+
initialize: async () => {
|
|
38
|
+
const { isInitialized } = get();
|
|
39
|
+
await initManager.initialize(isInitialized, set);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
setLanguage: async (languageCode: string) => {
|
|
43
|
+
await switchManager.setLanguage(languageCode, set);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
reset: () => {
|
|
47
|
+
initManager.reset();
|
|
48
|
+
switchManager.reset();
|
|
49
|
+
|
|
50
|
+
set({
|
|
51
|
+
currentLanguage: "en-US",
|
|
52
|
+
isRTL: false,
|
|
53
|
+
isInitialized: false,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Getters
|
|
58
|
+
getCurrentLanguage: () => {
|
|
59
|
+
const { currentLanguage } = get();
|
|
60
|
+
return localizationGetters.getCurrentLanguage(currentLanguage);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
isLanguageSupported: (code: string) => {
|
|
64
|
+
return localizationGetters.isLanguageSupported(code);
|
|
65
|
+
},
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
setLanguage: async (languageCode: string) => {
|
|
31
|
-
await switchManager.setLanguage(languageCode, set);
|
|
32
|
-
},
|
|
33
|
-
|
|
34
|
-
reset: () => {
|
|
35
|
-
initManager.reset();
|
|
36
|
-
switchManager.reset();
|
|
37
|
-
|
|
38
|
-
set({
|
|
39
|
-
currentLanguage: "en-US",
|
|
40
|
-
isRTL: false,
|
|
41
|
-
isInitialized: false,
|
|
42
|
-
});
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
// Getters
|
|
46
|
-
getCurrentLanguage: () => {
|
|
47
|
-
const { currentLanguage } = get();
|
|
48
|
-
return localizationGetters.getCurrentLanguage(currentLanguage);
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
isLanguageSupported: (code: string) => {
|
|
52
|
-
return localizationGetters.isLanguageSupported(code);
|
|
53
|
-
},
|
|
54
|
-
|
|
55
|
-
getSupportedLanguages: () => {
|
|
56
|
-
return localizationGetters.getSupportedLanguages();
|
|
57
|
-
},
|
|
58
|
-
}));
|
|
67
|
+
getSupportedLanguages: () => {
|
|
68
|
+
return localizationGetters.getSupportedLanguages();
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
NavigationHeader,
|
|
13
13
|
useAppNavigation,
|
|
14
14
|
} from '@umituz/react-native-design-system';
|
|
15
|
+
import { isDev } from '../../../../utils/devUtils';
|
|
15
16
|
import { useLanguageSelection } from '../../infrastructure/hooks/useLanguageSelection';
|
|
16
17
|
import { LanguageItem } from '../components/LanguageItem';
|
|
17
18
|
import type { Language } from '../../infrastructure/storage/types/Language';
|
|
@@ -38,14 +39,14 @@ export const LanguageSelectionScreen: React.FC<LanguageSelectionScreenProps> = (
|
|
|
38
39
|
} = useLanguageSelection();
|
|
39
40
|
|
|
40
41
|
const onSelect = async (code: string) => {
|
|
41
|
-
if (
|
|
42
|
+
if (isDev()) {
|
|
42
43
|
}
|
|
43
44
|
await handleLanguageSelect(code, () => {
|
|
44
|
-
if (
|
|
45
|
+
if (isDev()) {
|
|
45
46
|
}
|
|
46
47
|
navigation.goBack();
|
|
47
48
|
});
|
|
48
|
-
if (
|
|
49
|
+
if (isDev()) {
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
|
|
@@ -113,12 +114,21 @@ export const LanguageSelectionScreen: React.FC<LanguageSelectionScreenProps> = (
|
|
|
113
114
|
renderItem={renderItem}
|
|
114
115
|
keyExtractor={item => item.code}
|
|
115
116
|
contentContainerStyle={[
|
|
116
|
-
styles.listContent,
|
|
117
|
+
styles.listContent,
|
|
117
118
|
{ paddingBottom: tokens.spacing.xl },
|
|
118
119
|
customStyles?.listContent
|
|
119
120
|
]}
|
|
120
121
|
showsVerticalScrollIndicator={false}
|
|
121
122
|
keyboardShouldPersistTaps="handled"
|
|
123
|
+
initialNumToRender={15}
|
|
124
|
+
maxToRenderPerBatch={10}
|
|
125
|
+
windowSize={10}
|
|
126
|
+
removeClippedSubviews={true}
|
|
127
|
+
getItemLayout={(data, index) => ({
|
|
128
|
+
length: 64,
|
|
129
|
+
offset: 64 * index,
|
|
130
|
+
index,
|
|
131
|
+
})}
|
|
122
132
|
/>
|
|
123
133
|
</ScreenLayout>
|
|
124
134
|
);
|
|
@@ -3,75 +3,95 @@ import type { ScheduleNotificationOptions, ScheduledNotification } from './types
|
|
|
3
3
|
|
|
4
4
|
export class NotificationScheduler {
|
|
5
5
|
async scheduleNotification(options: ScheduleNotificationOptions): Promise<string> {
|
|
6
|
-
|
|
6
|
+
try {
|
|
7
|
+
const { title, body, data = {}, trigger, sound = true, badge, categoryIdentifier } = options;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
let notificationTrigger: Notifications.NotificationTriggerInput = null;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
11
|
+
if (trigger.type === 'date') {
|
|
12
|
+
notificationTrigger = {
|
|
13
|
+
date: trigger.date,
|
|
14
|
+
channelId: categoryIdentifier || 'default',
|
|
15
|
+
};
|
|
16
|
+
} else if (trigger.type === 'daily') {
|
|
17
|
+
notificationTrigger = {
|
|
18
|
+
hour: trigger.hour,
|
|
19
|
+
minute: trigger.minute,
|
|
20
|
+
repeats: true,
|
|
21
|
+
channelId: categoryIdentifier || 'reminders',
|
|
22
|
+
};
|
|
23
|
+
} else if (trigger.type === 'weekly') {
|
|
24
|
+
notificationTrigger = {
|
|
25
|
+
weekday: trigger.weekday,
|
|
26
|
+
hour: trigger.hour,
|
|
27
|
+
minute: trigger.minute,
|
|
28
|
+
repeats: true,
|
|
29
|
+
channelId: categoryIdentifier || 'reminders',
|
|
30
|
+
};
|
|
31
|
+
} else if (trigger.type === 'monthly') {
|
|
32
|
+
notificationTrigger = {
|
|
33
|
+
day: trigger.day,
|
|
34
|
+
hour: trigger.hour,
|
|
35
|
+
minute: trigger.minute,
|
|
36
|
+
repeats: true,
|
|
37
|
+
channelId: categoryIdentifier || 'reminders',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
const notificationId = await Notifications.scheduleNotificationAsync({
|
|
42
|
+
content: {
|
|
43
|
+
title,
|
|
44
|
+
body,
|
|
45
|
+
data,
|
|
46
|
+
sound: sound === true ? 'default' : sound || undefined,
|
|
47
|
+
badge,
|
|
48
|
+
categoryIdentifier,
|
|
49
|
+
priority: Notifications.AndroidNotificationPriority.HIGH,
|
|
50
|
+
vibrate: [0, 250, 250, 250],
|
|
51
|
+
},
|
|
52
|
+
trigger: notificationTrigger,
|
|
53
|
+
});
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
return notificationId;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Failed to schedule notification:', error);
|
|
58
|
+
throw new Error(`Notification scheduling failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
59
|
+
}
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
async cancelNotification(notificationId: string): Promise<void> {
|
|
58
|
-
|
|
63
|
+
try {
|
|
64
|
+
await Notifications.cancelScheduledNotificationAsync(notificationId);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Failed to cancel notification:', notificationId, error);
|
|
67
|
+
// Don't throw - canceling a non-existent notification is not critical
|
|
68
|
+
}
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
async cancelAllNotifications(): Promise<void> {
|
|
62
|
-
|
|
72
|
+
try {
|
|
73
|
+
await Notifications.cancelAllScheduledNotificationsAsync();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Failed to cancel all notifications:', error);
|
|
76
|
+
throw new Error(`Failed to cancel notifications: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
77
|
+
}
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
async getScheduledNotifications(): Promise<ScheduledNotification[]> {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
try {
|
|
82
|
+
const notifications = await Notifications.getAllScheduledNotificationsAsync();
|
|
83
|
+
return notifications.map(notification => ({
|
|
84
|
+
identifier: notification.identifier,
|
|
85
|
+
content: {
|
|
86
|
+
title: notification.content.title ?? '',
|
|
87
|
+
body: notification.content.body ?? '',
|
|
88
|
+
data: notification.content.data as Record<string, string | number | boolean | null>,
|
|
89
|
+
},
|
|
90
|
+
trigger: notification.trigger,
|
|
91
|
+
}));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Failed to get scheduled notifications:', error);
|
|
94
|
+
return []; // Return empty array as fallback
|
|
95
|
+
}
|
|
76
96
|
}
|
|
77
97
|
}
|
|
@@ -10,10 +10,12 @@ export const buildTrigger = (reminder: Reminder): NotificationTrigger => {
|
|
|
10
10
|
|
|
11
11
|
switch (frequency) {
|
|
12
12
|
case 'once': {
|
|
13
|
+
const now = new Date();
|
|
13
14
|
const date = new Date();
|
|
14
15
|
date.setHours(hour, minute, 0, 0);
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// If scheduled time has passed today, schedule for tomorrow
|
|
18
|
+
if (date <= now) {
|
|
17
19
|
date.setDate(date.getDate() + 1);
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -12,28 +12,37 @@ export const useQuietHoursActions = () => {
|
|
|
12
12
|
const { updateQuietHours } = usePreferencesStore();
|
|
13
13
|
|
|
14
14
|
const setQuietHoursEnabled = useCallback(async (enabled: boolean): Promise<void> => {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Use getState() to avoid stale closure and race conditions
|
|
16
|
+
const currentQuietHours = usePreferencesStore.getState().quietHours;
|
|
17
|
+
await updateQuietHours({ ...currentQuietHours, enabled });
|
|
18
|
+
}, [updateQuietHours]);
|
|
17
19
|
|
|
18
20
|
const setStartTime = useCallback(async (hour: number, minute: number): Promise<void> => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
// Use getState() to avoid stale closure and race conditions
|
|
22
|
+
const currentQuietHours = usePreferencesStore.getState().quietHours;
|
|
23
|
+
await updateQuietHours({ ...currentQuietHours, startHour: hour, startMinute: minute });
|
|
24
|
+
}, [updateQuietHours]);
|
|
21
25
|
|
|
22
26
|
const setEndTime = useCallback(async (hour: number, minute: number): Promise<void> => {
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
// Use getState() to avoid stale closure and race conditions
|
|
28
|
+
const currentQuietHours = usePreferencesStore.getState().quietHours;
|
|
29
|
+
await updateQuietHours({ ...currentQuietHours, endHour: hour, endMinute: minute });
|
|
30
|
+
}, [updateQuietHours]);
|
|
25
31
|
|
|
26
32
|
const setQuietHours = useCallback(async (config: QuietHoursConfig): Promise<void> => {
|
|
27
33
|
await updateQuietHours(config);
|
|
28
34
|
}, [updateQuietHours]);
|
|
29
35
|
|
|
30
36
|
const isInQuietHours = useCallback((): boolean => {
|
|
31
|
-
|
|
37
|
+
// Use getState() to get current quietHours for consistency
|
|
38
|
+
const currentQuietHours = usePreferencesStore.getState().quietHours;
|
|
39
|
+
|
|
40
|
+
if (!currentQuietHours.enabled) return false;
|
|
32
41
|
|
|
33
42
|
const now = new Date();
|
|
34
43
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
35
|
-
const startMinutes =
|
|
36
|
-
const endMinutes =
|
|
44
|
+
const startMinutes = currentQuietHours.startHour * 60 + currentQuietHours.startMinute;
|
|
45
|
+
const endMinutes = currentQuietHours.endHour * 60 + currentQuietHours.endMinute;
|
|
37
46
|
|
|
38
47
|
// Validate time values
|
|
39
48
|
if (startMinutes < 0 || startMinutes >= 1440 || endMinutes < 0 || endMinutes >= 1440) {
|
|
@@ -49,7 +58,7 @@ export const useQuietHoursActions = () => {
|
|
|
49
58
|
// Current time is after start OR before end
|
|
50
59
|
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
51
60
|
}
|
|
52
|
-
}, [
|
|
61
|
+
}, []);
|
|
53
62
|
|
|
54
63
|
return {
|
|
55
64
|
quietHours,
|
|
@@ -112,6 +112,15 @@ export const ReminderListScreen: React.FC<ReminderListScreenProps> = ({
|
|
|
112
112
|
ListEmptyComponent={renderEmpty}
|
|
113
113
|
contentContainerStyle={styles.listContent}
|
|
114
114
|
showsVerticalScrollIndicator={false}
|
|
115
|
+
initialNumToRender={10}
|
|
116
|
+
maxToRenderPerBatch={5}
|
|
117
|
+
windowSize={10}
|
|
118
|
+
removeClippedSubviews={true}
|
|
119
|
+
getItemLayout={(data, index) => ({
|
|
120
|
+
length: 88,
|
|
121
|
+
offset: 88 * index,
|
|
122
|
+
index,
|
|
123
|
+
})}
|
|
115
124
|
/>
|
|
116
125
|
|
|
117
126
|
{canAddMore && (
|
|
@@ -40,7 +40,7 @@ export async function trackEvent(eventType: string): Promise<void> {
|
|
|
40
40
|
try {
|
|
41
41
|
await incrementEventCount(eventType);
|
|
42
42
|
} catch (error) {
|
|
43
|
-
|
|
43
|
+
console.error('[RatingService] Failed to track event:', eventType, error);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -90,7 +90,7 @@ export async function markPromptShown(eventType: string): Promise<void> {
|
|
|
90
90
|
try {
|
|
91
91
|
await setLastPromptDate(eventType, toISOString());
|
|
92
92
|
} catch (error) {
|
|
93
|
-
|
|
93
|
+
console.error('[RatingService] Failed to mark prompt shown:', eventType, error);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -101,7 +101,7 @@ export async function markRated(): Promise<void> {
|
|
|
101
101
|
try {
|
|
102
102
|
await setHasRated(true);
|
|
103
103
|
} catch (error) {
|
|
104
|
-
|
|
104
|
+
console.error('[RatingService] Failed to mark as rated:', error);
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -112,7 +112,7 @@ export async function markDismissed(): Promise<void> {
|
|
|
112
112
|
try {
|
|
113
113
|
await setDismissed(true);
|
|
114
114
|
} catch (error) {
|
|
115
|
-
|
|
115
|
+
console.error('[RatingService] Failed to mark as dismissed:', error);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -130,6 +130,6 @@ export async function reset(eventType?: string): Promise<void> {
|
|
|
130
130
|
try {
|
|
131
131
|
await resetStorage(eventType);
|
|
132
132
|
} catch (error) {
|
|
133
|
-
|
|
133
|
+
console.error('[RatingService] Failed to reset:', eventType, error);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
@@ -35,8 +35,8 @@ export async function getEventCount(eventType: string): Promise<number> {
|
|
|
35
35
|
export async function setEventCount(eventType: string, count: number): Promise<void> {
|
|
36
36
|
try {
|
|
37
37
|
await storageRepository.setString(KEYS.eventCount(eventType), count.toString());
|
|
38
|
-
} catch {
|
|
39
|
-
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[RatingStorage] Failed to set event count:', eventType, count, error);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -67,8 +67,8 @@ export async function getLastPromptDate(eventType: string): Promise<string | nul
|
|
|
67
67
|
export async function setLastPromptDate(eventType: string, date: string): Promise<void> {
|
|
68
68
|
try {
|
|
69
69
|
await storageRepository.setString(KEYS.lastPrompt(eventType), date);
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('[RatingStorage] Failed to set last prompt date:', eventType, date, error);
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -90,8 +90,8 @@ export async function getHasRated(): Promise<boolean> {
|
|
|
90
90
|
export async function setHasRated(value: boolean): Promise<void> {
|
|
91
91
|
try {
|
|
92
92
|
await storageRepository.setString(KEYS.hasRated, value.toString());
|
|
93
|
-
} catch {
|
|
94
|
-
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[RatingStorage] Failed to set has rated:', value, error);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -113,8 +113,8 @@ export async function getDismissed(): Promise<boolean> {
|
|
|
113
113
|
export async function setDismissed(value: boolean): Promise<void> {
|
|
114
114
|
try {
|
|
115
115
|
await storageRepository.setString(KEYS.dismissed, value.toString());
|
|
116
|
-
} catch {
|
|
117
|
-
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error('[RatingStorage] Failed to set dismissed:', value, error);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -154,7 +154,7 @@ export async function reset(eventType?: string): Promise<void> {
|
|
|
154
154
|
ratingKeys.map((key) => storageRepository.removeItem(key))
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
|
-
} catch {
|
|
158
|
-
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('[RatingStorage] Failed to reset rating data:', eventType, error);
|
|
159
159
|
}
|
|
160
160
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
2
|
import { View, StyleSheet, TouchableOpacity, ViewStyle } from "react-native";
|
|
3
3
|
import { useAppDesignTokens, AtomicIcon } from "@umituz/react-native-design-system";
|
|
4
4
|
|
|
@@ -27,6 +27,11 @@ export const StarRating: React.FC<StarRatingProps> = ({
|
|
|
27
27
|
const styles = getStyles(tokens);
|
|
28
28
|
const [internalRating, setInternalRating] = useState(rating);
|
|
29
29
|
|
|
30
|
+
// Sync internal state with rating prop changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setInternalRating(rating);
|
|
33
|
+
}, [rating]);
|
|
34
|
+
|
|
30
35
|
const filledColor = activeColor || tokens.colors.warning;
|
|
31
36
|
const emptyColor = inactiveColor || tokens.colors.borderLight;
|
|
32
37
|
|
|
@@ -110,6 +110,10 @@ export const VideoTutorialsScreen: React.FC<VideoTutorialsScreenProps> = React.m
|
|
|
110
110
|
horizontal
|
|
111
111
|
showsHorizontalScrollIndicator={false}
|
|
112
112
|
contentContainerStyle={styles.horizontalList}
|
|
113
|
+
initialNumToRender={3}
|
|
114
|
+
maxToRenderPerBatch={2}
|
|
115
|
+
windowSize={5}
|
|
116
|
+
removeClippedSubviews={true}
|
|
113
117
|
/>
|
|
114
118
|
</View>
|
|
115
119
|
)}
|
|
@@ -128,6 +132,10 @@ export const VideoTutorialsScreen: React.FC<VideoTutorialsScreenProps> = React.m
|
|
|
128
132
|
showsVerticalScrollIndicator={false}
|
|
129
133
|
contentContainerStyle={styles.verticalList}
|
|
130
134
|
ListHeaderComponent={ListHeader}
|
|
135
|
+
initialNumToRender={8}
|
|
136
|
+
maxToRenderPerBatch={5}
|
|
137
|
+
windowSize={10}
|
|
138
|
+
removeClippedSubviews={true}
|
|
131
139
|
/>
|
|
132
140
|
</ScreenLayout>
|
|
133
141
|
);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { storageRepository, StorageKey, createUserKey } from '@umituz/react-native-design-system';
|
|
8
8
|
import type { ISettingsRepository, UserSettings, SettingsResult } from '../../application/ports/ISettingsRepository';
|
|
9
|
+
import { TEXT_LENGTH_LIMITS } from '../utils/constants/textLimits';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Validates userId to prevent key injection attacks
|
|
@@ -19,7 +20,7 @@ const validateUserId = (userId: string): void => {
|
|
|
19
20
|
throw new Error('Invalid userId: contains invalid characters');
|
|
20
21
|
}
|
|
21
22
|
// Limit length to prevent DoS attacks
|
|
22
|
-
if (userId.length >
|
|
23
|
+
if (userId.length > TEXT_LENGTH_LIMITS.USER_ID) {
|
|
23
24
|
throw new Error('Invalid userId: exceeds maximum length');
|
|
24
25
|
}
|
|
25
26
|
};
|
|
@@ -49,9 +49,17 @@ export const withTimeout = async <T>(
|
|
|
49
49
|
timeoutMs: number,
|
|
50
50
|
timeoutMessage: string = "Operation timed out"
|
|
51
51
|
): Promise<T> => {
|
|
52
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
53
|
+
|
|
52
54
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
53
|
-
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
55
|
+
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
try {
|
|
59
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
60
|
+
} finally {
|
|
61
|
+
if (timeoutId) {
|
|
62
|
+
clearTimeout(timeoutId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
57
65
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Length Limits
|
|
3
|
+
* Standardized text length constraints used across the application
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const TEXT_LENGTH_LIMITS = {
|
|
7
|
+
/** Short titles and labels (100 characters) */
|
|
8
|
+
TITLE_SHORT: 100,
|
|
9
|
+
|
|
10
|
+
/** Standard titles (200 characters) */
|
|
11
|
+
TITLE: 200,
|
|
12
|
+
|
|
13
|
+
/** Descriptions and short text (500 characters) */
|
|
14
|
+
DESCRIPTION: 500,
|
|
15
|
+
|
|
16
|
+
/** Long text and body content (1000 characters) */
|
|
17
|
+
BODY: 1000,
|
|
18
|
+
|
|
19
|
+
/** User ID maximum length (128 characters) */
|
|
20
|
+
USER_ID: 128,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type TextLengthLimit = typeof TEXT_LENGTH_LIMITS[keyof typeof TEXT_LENGTH_LIMITS];
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Provides sanitization functions for user input and props.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { TEXT_LENGTH_LIMITS } from './constants/textLimits';
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Sanitize string by trimming and limiting length
|
|
9
11
|
*/
|
|
@@ -16,14 +18,14 @@ export function sanitizeString(str: string | undefined, maxLength: number): stri
|
|
|
16
18
|
* Sanitize title prop
|
|
17
19
|
*/
|
|
18
20
|
export function sanitizeTitle(title: string): string {
|
|
19
|
-
return sanitizeString(title,
|
|
21
|
+
return sanitizeString(title, TEXT_LENGTH_LIMITS.TITLE) || "";
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Sanitize description prop
|
|
24
26
|
*/
|
|
25
27
|
export function sanitizeDescription(description: string | undefined): string | undefined {
|
|
26
|
-
return sanitizeString(description || "",
|
|
28
|
+
return sanitizeString(description || "", TEXT_LENGTH_LIMITS.DESCRIPTION);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -55,6 +55,19 @@ export const OPACITY = {
|
|
|
55
55
|
icon: 0.6,
|
|
56
56
|
} as const;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Alpha/Transparency values for withAlpha() usage
|
|
60
|
+
* Used with design system's withAlpha(color, alpha) function
|
|
61
|
+
*/
|
|
62
|
+
export const ALPHA_VALUES = {
|
|
63
|
+
subtle: 0.1, // Very subtle tint
|
|
64
|
+
light: 0.15, // Light background tint
|
|
65
|
+
medium: 0.2, // Medium background
|
|
66
|
+
strong: 0.3, // Strong background
|
|
67
|
+
visible: 0.5, // Clearly visible
|
|
68
|
+
prominent: 0.8, // Almost opaque
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
58
71
|
/**
|
|
59
72
|
* Icon size tokens
|
|
60
73
|
*/
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ValidationResult } from "./core";
|
|
7
7
|
import { validateRating } from "./numericValidators";
|
|
8
|
+
import { TEXT_LENGTH_LIMITS } from "../constants/textLimits";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Feedback form validation
|
|
@@ -30,8 +31,8 @@ export const validateFeedbackForm = (data: {
|
|
|
30
31
|
return { isValid: false, error: "Description must be at least 10 characters" };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
if (data.description.length >
|
|
34
|
-
return { isValid: false, error:
|
|
34
|
+
if (data.description.length > TEXT_LENGTH_LIMITS.BODY) {
|
|
35
|
+
return { isValid: false, error: `Description must be less than ${TEXT_LENGTH_LIMITS.BODY} characters` };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
return { isValid: true };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withErrorBoundary HOC
|
|
3
|
+
* Wraps screens with SettingsErrorBoundary for error handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { SettingsErrorBoundary } from './SettingsErrorBoundary';
|
|
8
|
+
|
|
9
|
+
export interface WithErrorBoundaryOptions {
|
|
10
|
+
domainName: string;
|
|
11
|
+
fallback?: React.ReactNode;
|
|
12
|
+
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Higher-Order Component to wrap screens with ErrorBoundary
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* export const AppearanceScreen = withErrorBoundary(
|
|
21
|
+
* AppearanceScreenComponent,
|
|
22
|
+
* { domainName: 'Appearance' }
|
|
23
|
+
* );
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function withErrorBoundary<P extends object>(
|
|
27
|
+
Component: React.ComponentType<P>,
|
|
28
|
+
options: WithErrorBoundaryOptions
|
|
29
|
+
): React.FC<P> {
|
|
30
|
+
const WrappedComponent: React.FC<P> = (props) => {
|
|
31
|
+
return (
|
|
32
|
+
<SettingsErrorBoundary
|
|
33
|
+
domainName={options.domainName}
|
|
34
|
+
fallback={options.fallback}
|
|
35
|
+
onError={options.onError}
|
|
36
|
+
>
|
|
37
|
+
<Component {...props} />
|
|
38
|
+
</SettingsErrorBoundary>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || 'Component'})`;
|
|
43
|
+
|
|
44
|
+
return WrappedComponent;
|
|
45
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@umituz/react-native-design-system";
|
|
8
8
|
import { validateTitle, validateDescription, validateSwitchProps } from "../../infrastructure/utils/validators";
|
|
9
9
|
import { sanitizeTitle, sanitizeDescription } from "../../infrastructure/utils/sanitizers";
|
|
10
|
+
import { TEXT_LENGTH_LIMITS } from "../../infrastructure/utils/constants/textLimits";
|
|
10
11
|
import { SettingsItemCardRightElement } from "./settings/SettingsItemCardRightElement";
|
|
11
12
|
import { SettingsItemCardContent } from "./settings/SettingsItemCardContent";
|
|
12
13
|
import { SettingsItemCardSection } from "./settings/SettingsItemCardSection";
|
|
@@ -54,7 +55,7 @@ export const SettingsItemCard: React.FC<SettingsItemCardProps> = ({
|
|
|
54
55
|
// Sanitize props (before hooks)
|
|
55
56
|
const sanitizedTitle = sanitizeTitle(title);
|
|
56
57
|
const sanitizedDescription = sanitizeDescription(description);
|
|
57
|
-
const sanitizedSectionTitle = sectionTitle?.trim().slice(0,
|
|
58
|
+
const sanitizedSectionTitle = sectionTitle?.trim().slice(0, TEXT_LENGTH_LIMITS.TITLE_SHORT);
|
|
58
59
|
|
|
59
60
|
const defaultIconBg = iconBgColor || withAlpha(colors.primary, 0.15);
|
|
60
61
|
const defaultIconColor = iconColor || colors.primary;
|
|
@@ -20,17 +20,44 @@ export const useNavigationHandlers = (
|
|
|
20
20
|
legalUrls: LegalUrls,
|
|
21
21
|
aboutTranslations?: AboutConfig["texts"]
|
|
22
22
|
): NavigationHandlersResult => {
|
|
23
|
-
const handlePrivacyPress = useCallback(() => {
|
|
24
|
-
|
|
23
|
+
const handlePrivacyPress = useCallback(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const canOpen = await Linking.canOpenURL(legalUrls.privacy);
|
|
26
|
+
if (canOpen) {
|
|
27
|
+
await Linking.openURL(legalUrls.privacy);
|
|
28
|
+
} else {
|
|
29
|
+
console.warn('Cannot open privacy policy URL:', legalUrls.privacy);
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('Failed to open privacy policy:', error);
|
|
33
|
+
}
|
|
25
34
|
}, [legalUrls.privacy]);
|
|
26
35
|
|
|
27
|
-
const handleTermsPress = useCallback(() => {
|
|
28
|
-
|
|
36
|
+
const handleTermsPress = useCallback(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const canOpen = await Linking.canOpenURL(legalUrls.terms);
|
|
39
|
+
if (canOpen) {
|
|
40
|
+
await Linking.openURL(legalUrls.terms);
|
|
41
|
+
} else {
|
|
42
|
+
console.warn('Cannot open terms of service URL:', legalUrls.terms);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Failed to open terms of service:', error);
|
|
46
|
+
}
|
|
29
47
|
}, [legalUrls.terms]);
|
|
30
48
|
|
|
31
|
-
const handleEulaPress = useCallback(() => {
|
|
49
|
+
const handleEulaPress = useCallback(async () => {
|
|
32
50
|
if (legalUrls.eula) {
|
|
33
|
-
|
|
51
|
+
try {
|
|
52
|
+
const canOpen = await Linking.canOpenURL(legalUrls.eula);
|
|
53
|
+
if (canOpen) {
|
|
54
|
+
await Linking.openURL(legalUrls.eula);
|
|
55
|
+
} else {
|
|
56
|
+
console.warn('Cannot open EULA URL:', legalUrls.eula);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to open EULA:', error);
|
|
60
|
+
}
|
|
34
61
|
}
|
|
35
62
|
}, [legalUrls.eula]);
|
|
36
63
|
|