@umituz/react-native-settings 4.23.64 → 4.23.66
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/about/presentation/hooks/useAboutInfo.ts +16 -5
- package/src/domains/appearance/hooks/useAppearanceActions.ts +25 -9
- package/src/domains/appearance/presentation/components/AppearancePreview.tsx +3 -3
- package/src/domains/appearance/presentation/components/ColorPicker.tsx +10 -4
- package/src/domains/appearance/presentation/components/ThemeModeSection.tsx +2 -2
- package/src/domains/appearance/presentation/components/ThemeOption.tsx +41 -8
- package/src/domains/gamification/components/GamificationScreen/types.ts +1 -0
- package/src/domains/gamification/store/gamificationStore.ts +7 -1
- package/src/domains/legal/presentation/components/LegalDocumentsList.tsx +6 -6
- package/src/domains/localization/presentation/providers/LocalizationProvider.tsx +18 -3
- package/src/domains/localization/presentation/screens/LanguageSelectionScreen.types.ts +1 -0
- package/src/domains/notifications/infrastructure/services/types.ts +7 -4
- package/src/domains/notifications/quietHours/infrastructure/hooks/useQuietHoursActions.ts +12 -1
- package/src/domains/notifications/quietHours/presentation/components/QuietHoursCard.tsx +19 -2
- package/src/domains/notifications/reminders/presentation/components/ReminderForm.tsx +67 -7
- package/src/infrastructure/repositories/SettingsRepository.ts +26 -0
- package/src/presentation/components/SettingsErrorBoundary.tsx +25 -13
- package/src/presentation/components/SettingsSection.tsx +4 -2
- package/src/presentation/hooks/mutations/useSettingsMutations.ts +14 -5
- package/src/presentation/hooks/useSettingsScreenConfig.ts +29 -18
- package/src/presentation/navigation/SettingsStackNavigator.tsx +65 -41
- package/src/presentation/navigation/types.ts +1 -0
- package/src/presentation/screens/SettingsScreen.tsx +40 -9
- package/src/presentation/screens/components/sections/CustomSettingsList.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-settings",
|
|
3
|
-
"version": "4.23.
|
|
3
|
+
"version": "4.23.66",
|
|
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",
|
|
@@ -99,7 +99,12 @@ export const useAboutInfo = (
|
|
|
99
99
|
isMountedRef.current = false;
|
|
100
100
|
|
|
101
101
|
if (repository && typeof repository.destroy === 'function') {
|
|
102
|
-
|
|
102
|
+
try {
|
|
103
|
+
repository.destroy();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Log cleanup error but don't throw
|
|
106
|
+
console.warn('Error during repository cleanup:', error);
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
};
|
|
105
110
|
}, [repository]);
|
|
@@ -112,10 +117,16 @@ export const useAboutInfo = (
|
|
|
112
117
|
isMountedRef.current &&
|
|
113
118
|
!isInitializedRef.current
|
|
114
119
|
) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
// Dynamic import to avoid require issues
|
|
121
|
+
import('../../utils/AppInfoFactory').then(({ createDefaultAppInfo }) => {
|
|
122
|
+
if (isMountedRef.current) {
|
|
123
|
+
const defaultAppInfo = createDefaultAppInfo(initialConfig);
|
|
124
|
+
setAppInfo(defaultAppInfo);
|
|
125
|
+
isInitializedRef.current = true;
|
|
126
|
+
}
|
|
127
|
+
}).catch((error) => {
|
|
128
|
+
console.error('Failed to load AppInfoFactory:', error);
|
|
129
|
+
});
|
|
119
130
|
}
|
|
120
131
|
}, [initialConfig, autoInit]);
|
|
121
132
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { useAppearance } from "./useAppearance";
|
|
3
3
|
import type { CustomThemeColors, ThemeMode } from "@umituz/react-native-design-system";
|
|
4
4
|
|
|
@@ -6,8 +6,19 @@ export const useAppearanceActions = () => {
|
|
|
6
6
|
const { themeMode, customColors, setThemeMode, setCustomColors, reset } = useAppearance();
|
|
7
7
|
const [localCustomColors, setLocalCustomColors] = useState<CustomThemeColors>(customColors || {});
|
|
8
8
|
|
|
9
|
+
// Track if component is mounted to prevent state updates after unmount
|
|
10
|
+
const isMountedRef = useRef(true);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
isMountedRef.current = true;
|
|
14
|
+
return () => {
|
|
15
|
+
isMountedRef.current = false;
|
|
16
|
+
};
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
// Sync local state with global state, but only if mounted
|
|
9
20
|
useEffect(() => {
|
|
10
|
-
if (customColors) {
|
|
21
|
+
if (isMountedRef.current && customColors) {
|
|
11
22
|
setLocalCustomColors(customColors);
|
|
12
23
|
}
|
|
13
24
|
}, [customColors]);
|
|
@@ -17,16 +28,21 @@ export const useAppearanceActions = () => {
|
|
|
17
28
|
}, [setThemeMode]);
|
|
18
29
|
|
|
19
30
|
const handleColorChange = useCallback((key: keyof CustomThemeColors, color: string) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// Update local state immediately for UI responsiveness
|
|
32
|
+
const newColors = { ...localCustomColors, [key]: color };
|
|
33
|
+
|
|
34
|
+
// Batch state updates to prevent race conditions
|
|
35
|
+
setLocalCustomColors(newColors);
|
|
36
|
+
|
|
37
|
+
// Update global state separately
|
|
38
|
+
setCustomColors(newColors);
|
|
39
|
+
}, [localCustomColors, setCustomColors]);
|
|
26
40
|
|
|
27
41
|
const handleResetColors = useCallback(() => {
|
|
28
42
|
reset();
|
|
29
|
-
|
|
43
|
+
if (isMountedRef.current) {
|
|
44
|
+
setLocalCustomColors({});
|
|
45
|
+
}
|
|
30
46
|
}, [reset]);
|
|
31
47
|
|
|
32
48
|
return {
|
|
@@ -59,7 +59,7 @@ export const AppearancePreview: React.FC<AppearancePreviewProps> = ({
|
|
|
59
59
|
|
|
60
60
|
const colors = previewColors.map((item) => ({
|
|
61
61
|
label: item.label,
|
|
62
|
-
color: localCustomColors[item.key]
|
|
62
|
+
color: localCustomColors[item.key] ?? item.fallbackColor,
|
|
63
63
|
}));
|
|
64
64
|
|
|
65
65
|
if (!showPreview) {
|
|
@@ -84,8 +84,8 @@ export const AppearancePreview: React.FC<AppearancePreviewProps> = ({
|
|
|
84
84
|
</AtomicText>
|
|
85
85
|
<View style={styles.previewContainer}>
|
|
86
86
|
<View style={styles.previewColorRow}>
|
|
87
|
-
{colors.map((item
|
|
88
|
-
<View key={
|
|
87
|
+
{colors.map((item) => (
|
|
88
|
+
<View key={`preview-color-${item.label}`} style={styles.previewColorItem}>
|
|
89
89
|
<View
|
|
90
90
|
style={[
|
|
91
91
|
styles.previewColorCircle,
|
|
@@ -40,14 +40,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
40
40
|
// Prevent unnecessary updates if color hasn't changed
|
|
41
41
|
if (value === color) return;
|
|
42
42
|
onValueChange(color);
|
|
43
|
-
} catch {
|
|
44
|
-
//
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Log error for debugging while preventing UI crashes
|
|
45
|
+
console.error('Failed to change color:', error);
|
|
46
|
+
// Optionally: Show user feedback about the error
|
|
45
47
|
}
|
|
46
48
|
}, [value, onValueChange]);
|
|
47
49
|
|
|
48
50
|
// Memoize color options to prevent unnecessary re-renders
|
|
49
51
|
const colorOptions = useMemo(() => {
|
|
50
|
-
return colorsMemo.map((color) => {
|
|
52
|
+
return colorsMemo.map((color, index) => {
|
|
51
53
|
const isSelected = value === color;
|
|
52
54
|
|
|
53
55
|
return (
|
|
@@ -60,6 +62,10 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
60
62
|
]}
|
|
61
63
|
onPress={() => handleColorChange(color)}
|
|
62
64
|
activeOpacity={0.8} // Performance optimization
|
|
65
|
+
accessibilityLabel={`Color ${color} ${isSelected ? 'selected' : 'not selected'}`}
|
|
66
|
+
accessibilityRole={isSelected ? 'button' : 'button'}
|
|
67
|
+
accessibilityState={{ selected: isSelected }}
|
|
68
|
+
accessibilityHint={`Select ${color} color`}
|
|
63
69
|
>
|
|
64
70
|
{isSelected && (
|
|
65
71
|
<AtomicIcon name="checkmark" size="sm" customColor={tokens.colors.textInverse} />
|
|
@@ -67,7 +73,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
67
73
|
</TouchableOpacity>
|
|
68
74
|
);
|
|
69
75
|
});
|
|
70
|
-
}, [colorsMemo, value, handleColorChange, styles]);
|
|
76
|
+
}, [colorsMemo, value, handleColorChange, styles, tokens.colors.textInverse]);
|
|
71
77
|
|
|
72
78
|
return (
|
|
73
79
|
<View style={styles.container}>
|
|
@@ -43,8 +43,8 @@ export const ThemeModeSection: React.FC<ThemeModeSectionProps> = ({
|
|
|
43
43
|
const handleThemeSelect = useCallback((mode: ThemeMode) => {
|
|
44
44
|
try {
|
|
45
45
|
onThemeSelect(mode);
|
|
46
|
-
} catch {
|
|
47
|
-
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Failed to select theme:', error);
|
|
48
48
|
}
|
|
49
49
|
}, [onThemeSelect]);
|
|
50
50
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Single Responsibility: Render theme option UI
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React from "react";
|
|
8
|
+
import React, { useMemo } from "react";
|
|
9
9
|
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
10
10
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system";
|
|
11
11
|
import { useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
@@ -22,7 +22,24 @@ interface ThemeOptionProps {
|
|
|
22
22
|
onSelect: () => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Valid theme modes for validation
|
|
26
|
+
const VALID_THEME_MODES: readonly ThemeMode[] = ["light", "dark", "auto"];
|
|
27
|
+
|
|
28
|
+
// Icon names mapping for type safety
|
|
29
|
+
const THEME_ICONS: Record<ThemeMode, string> = {
|
|
30
|
+
light: "sunny-outline",
|
|
31
|
+
dark: "moon-outline",
|
|
32
|
+
auto: "desktop-outline",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Utility function to add opacity to hex color
|
|
36
|
+
const addOpacityToHex = (hexColor: string, opacity: string): string => {
|
|
37
|
+
// Remove # if present
|
|
38
|
+
const color = hexColor.replace("#", "");
|
|
39
|
+
return `#${color}${opacity}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const ThemeOption: React.FC<ThemeOptionProps> = React.memo(({
|
|
26
43
|
mode,
|
|
27
44
|
title,
|
|
28
45
|
subtitle,
|
|
@@ -33,13 +50,27 @@ export const ThemeOption: React.FC<ThemeOptionProps> = ({
|
|
|
33
50
|
onSelect,
|
|
34
51
|
}) => {
|
|
35
52
|
const tokens = useAppDesignTokens();
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
|
|
54
|
+
// Memoize styles to prevent unnecessary re-creation
|
|
55
|
+
const styles = useMemo(() => getStyles(tokens), [tokens]);
|
|
56
|
+
|
|
57
|
+
// Type-safe icon name selection with validation
|
|
58
|
+
const iconName: string = useMemo(() => {
|
|
59
|
+
if (VALID_THEME_MODES.includes(mode)) {
|
|
60
|
+
return THEME_ICONS[mode];
|
|
61
|
+
}
|
|
62
|
+
// Fallback for invalid mode
|
|
63
|
+
console.warn(`Invalid theme mode: ${mode}. Defaulting to auto.`);
|
|
64
|
+
return THEME_ICONS.auto;
|
|
65
|
+
}, [mode]);
|
|
38
66
|
|
|
39
67
|
return (
|
|
40
68
|
<TouchableOpacity
|
|
41
69
|
style={[styles.container, isSelected && styles.selectedContainer]}
|
|
42
70
|
onPress={onSelect}
|
|
71
|
+
accessibilityLabel={`${title} theme ${isSelected ? 'selected' : 'not selected'}`}
|
|
72
|
+
accessibilityRole="button"
|
|
73
|
+
accessibilityState={{ selected: isSelected }}
|
|
43
74
|
>
|
|
44
75
|
<View style={styles.header}>
|
|
45
76
|
<View style={styles.iconContainer}>
|
|
@@ -85,7 +116,7 @@ export const ThemeOption: React.FC<ThemeOptionProps> = ({
|
|
|
85
116
|
) : null}
|
|
86
117
|
{features.map((feature, index) => (
|
|
87
118
|
<AtomicText
|
|
88
|
-
key={index}
|
|
119
|
+
key={`feature-${index}-${feature.slice(0, 10)}`}
|
|
89
120
|
type="bodySmall"
|
|
90
121
|
color="secondary"
|
|
91
122
|
style={styles.feature}
|
|
@@ -97,7 +128,9 @@ export const ThemeOption: React.FC<ThemeOptionProps> = ({
|
|
|
97
128
|
) : null}
|
|
98
129
|
</TouchableOpacity>
|
|
99
130
|
);
|
|
100
|
-
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ThemeOption.displayName = "ThemeOption";
|
|
101
134
|
|
|
102
135
|
const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
103
136
|
StyleSheet.create({
|
|
@@ -121,7 +154,7 @@ const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
|
121
154
|
width: 48,
|
|
122
155
|
height: 48,
|
|
123
156
|
borderRadius: 24,
|
|
124
|
-
backgroundColor:
|
|
157
|
+
backgroundColor: addOpacityToHex(tokens.colors.primary, "15"),
|
|
125
158
|
justifyContent: "center",
|
|
126
159
|
alignItems: "center",
|
|
127
160
|
marginRight: 12,
|
|
@@ -134,7 +167,7 @@ const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
|
134
167
|
lineHeight: 20,
|
|
135
168
|
},
|
|
136
169
|
featuresContainer: {
|
|
137
|
-
backgroundColor:
|
|
170
|
+
backgroundColor: addOpacityToHex(tokens.colors.primary, "08"),
|
|
138
171
|
borderRadius: 8,
|
|
139
172
|
padding: 12,
|
|
140
173
|
},
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Generic types for 100+ apps - NO app-specific code
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type React from 'react';
|
|
6
7
|
import type { LevelProgressProps } from "../LevelProgress";
|
|
7
8
|
import type { StatsCardProps } from "../StatsCard";
|
|
8
9
|
import type { AchievementItemProps } from "../AchievementItem";
|
|
@@ -116,10 +116,16 @@ export const useGamificationStore = createStore<GamificationState, GamificationA
|
|
|
116
116
|
});
|
|
117
117
|
},
|
|
118
118
|
|
|
119
|
-
checkAchievements: () => {
|
|
119
|
+
checkAchievements: (): Achievement[] => {
|
|
120
120
|
if (!currentConfig) return [];
|
|
121
121
|
|
|
122
122
|
const state = get();
|
|
123
|
+
|
|
124
|
+
// Safety check for achievements array
|
|
125
|
+
if (!state.achievements || state.achievements.length === 0) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
123
129
|
const newlyUnlocked: Achievement[] = [];
|
|
124
130
|
|
|
125
131
|
const updatedAchievements = state.achievements.map((ach: Achievement) => {
|
|
@@ -70,30 +70,30 @@ export const LegalDocumentsList: React.FC<LegalDocumentsListProps> = React.memo(
|
|
|
70
70
|
</AtomicText>
|
|
71
71
|
)}
|
|
72
72
|
|
|
73
|
-
{showPrivacy && (
|
|
73
|
+
{showPrivacy && privacyTitle && (
|
|
74
74
|
<LegalItem
|
|
75
75
|
iconName="shield"
|
|
76
|
-
title={privacyTitle
|
|
76
|
+
title={privacyTitle}
|
|
77
77
|
description={privacyDescription}
|
|
78
78
|
onPress={onPrivacyPress}
|
|
79
79
|
testID="privacy-policy-item"
|
|
80
80
|
/>
|
|
81
81
|
)}
|
|
82
82
|
|
|
83
|
-
{showTerms && (
|
|
83
|
+
{showTerms && termsTitle && (
|
|
84
84
|
<LegalItem
|
|
85
85
|
iconName="document-text"
|
|
86
|
-
title={termsTitle
|
|
86
|
+
title={termsTitle}
|
|
87
87
|
description={termsDescription}
|
|
88
88
|
onPress={onTermsPress}
|
|
89
89
|
testID="terms-of-service-item"
|
|
90
90
|
/>
|
|
91
91
|
)}
|
|
92
92
|
|
|
93
|
-
{showEula && (
|
|
93
|
+
{showEula && eulaTitle && (
|
|
94
94
|
<LegalItem
|
|
95
95
|
iconName="document"
|
|
96
|
-
title={eulaTitle
|
|
96
|
+
title={eulaTitle}
|
|
97
97
|
description={eulaDescription}
|
|
98
98
|
onPress={handleEulaPress}
|
|
99
99
|
testID="eula-item"
|
|
@@ -22,13 +22,28 @@ export const LocalizationProvider: React.FC<LocalizationProviderProps> = ({
|
|
|
22
22
|
const initialize = useLocalizationStore((state) => state.initialize);
|
|
23
23
|
|
|
24
24
|
useEffect(() => {
|
|
25
|
+
let isMounted = true;
|
|
26
|
+
|
|
25
27
|
const initializeLocalization = async () => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
|
+
I18nInitializer.initialize(translations, defaultLanguage);
|
|
30
|
+
await initialize();
|
|
31
|
+
if (isMounted) {
|
|
32
|
+
setIsI18nReady(true);
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('Failed to initialize localization:', error);
|
|
36
|
+
if (isMounted) {
|
|
37
|
+
setIsI18nReady(true); // Set ready even on error to prevent indefinite loading
|
|
38
|
+
}
|
|
39
|
+
}
|
|
29
40
|
};
|
|
30
41
|
|
|
31
42
|
initializeLocalization();
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
isMounted = false;
|
|
46
|
+
};
|
|
32
47
|
}, [translations, defaultLanguage, initialize]);
|
|
33
48
|
|
|
34
49
|
if (!isI18nReady) {
|
|
@@ -29,10 +29,13 @@ export type NotificationTrigger =
|
|
|
29
29
|
| { type: 'weekly'; weekday: number; hour: number; minute: number }
|
|
30
30
|
| { type: 'monthly'; day: number; hour: number; minute: number };
|
|
31
31
|
|
|
32
|
+
// More specific data type for notifications to prevent unsafe data
|
|
33
|
+
export type NotificationData = Record<string, string | number | boolean | null>;
|
|
34
|
+
|
|
32
35
|
export interface ScheduleNotificationOptions {
|
|
33
36
|
title: string;
|
|
34
37
|
body: string;
|
|
35
|
-
data?:
|
|
38
|
+
data?: NotificationData;
|
|
36
39
|
trigger: NotificationTrigger;
|
|
37
40
|
sound?: boolean | string;
|
|
38
41
|
badge?: number;
|
|
@@ -44,7 +47,7 @@ export interface ScheduledNotification {
|
|
|
44
47
|
content: {
|
|
45
48
|
title: string;
|
|
46
49
|
body: string;
|
|
47
|
-
data:
|
|
50
|
+
data: NotificationData;
|
|
48
51
|
};
|
|
49
52
|
trigger: Notifications.NotificationTrigger;
|
|
50
53
|
}
|
|
@@ -79,8 +82,8 @@ export interface Reminder {
|
|
|
79
82
|
dayOfMonth?: number;
|
|
80
83
|
enabled: boolean;
|
|
81
84
|
notificationId?: string;
|
|
82
|
-
createdAt: string;
|
|
83
|
-
updatedAt: string;
|
|
85
|
+
createdAt: string; // ISO 8601 date string
|
|
86
|
+
updatedAt: string; // ISO 8601 date string
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export interface CreateReminderInput {
|
|
@@ -35,10 +35,21 @@ export const useQuietHoursActions = () => {
|
|
|
35
35
|
const startMinutes = quietHours.startHour * 60 + quietHours.startMinute;
|
|
36
36
|
const endMinutes = quietHours.endHour * 60 + quietHours.endMinute;
|
|
37
37
|
|
|
38
|
+
// Validate time values
|
|
39
|
+
if (startMinutes < 0 || startMinutes >= 1440 || endMinutes < 0 || endMinutes >= 1440) {
|
|
40
|
+
console.error('Invalid quiet hours time values');
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle case where quiet hours span midnight
|
|
38
45
|
if (startMinutes <= endMinutes) {
|
|
46
|
+
// Same day period (e.g., 22:00 - 06:00 is not this case)
|
|
39
47
|
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
48
|
+
} else {
|
|
49
|
+
// Spans midnight (e.g., 22:00 - 06:00)
|
|
50
|
+
// Current time is after start OR before end
|
|
51
|
+
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
40
52
|
}
|
|
41
|
-
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
42
53
|
}, [quietHours]);
|
|
43
54
|
|
|
44
55
|
return {
|
|
@@ -48,12 +48,22 @@ export const QuietHoursCard: React.FC<QuietHoursCardProps> = ({
|
|
|
48
48
|
onValueChange={onToggle}
|
|
49
49
|
trackColor={{ false: tokens.colors.surfaceSecondary, true: tokens.colors.primary }}
|
|
50
50
|
thumbColor={tokens.colors.surface}
|
|
51
|
+
accessibilityLabel={translations.title}
|
|
52
|
+
accessibilityHint={translations.description}
|
|
53
|
+
accessibilityRole="switch"
|
|
51
54
|
/>
|
|
52
55
|
</View>
|
|
53
56
|
|
|
54
57
|
{config.enabled && (
|
|
55
58
|
<View style={styles.timeContainer}>
|
|
56
|
-
<TouchableOpacity
|
|
59
|
+
<TouchableOpacity
|
|
60
|
+
style={styles.timeButton}
|
|
61
|
+
onPress={onStartTimePress}
|
|
62
|
+
activeOpacity={0.7}
|
|
63
|
+
accessibilityLabel={`${translations.startTimeLabel}: ${formatTime(config.startHour, config.startMinute)}`}
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityHint="Press to change start time"
|
|
66
|
+
>
|
|
57
67
|
<AtomicText type="bodySmall" style={styles.timeLabel}>{translations.startTimeLabel}</AtomicText>
|
|
58
68
|
<AtomicText type="bodyLarge" style={styles.timeValue}>
|
|
59
69
|
{formatTime(config.startHour, config.startMinute)}
|
|
@@ -64,7 +74,14 @@ export const QuietHoursCard: React.FC<QuietHoursCardProps> = ({
|
|
|
64
74
|
<AtomicIcon name="arrow-forward" size="sm" color="secondary" />
|
|
65
75
|
</View>
|
|
66
76
|
|
|
67
|
-
<TouchableOpacity
|
|
77
|
+
<TouchableOpacity
|
|
78
|
+
style={styles.timeButton}
|
|
79
|
+
onPress={onEndTimePress}
|
|
80
|
+
activeOpacity={0.7}
|
|
81
|
+
accessibilityLabel={`${translations.endTimeLabel}: ${formatTime(config.endHour, config.endMinute)}`}
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityHint="Press to change end time"
|
|
84
|
+
>
|
|
68
85
|
<AtomicText type="bodySmall" style={styles.timeLabel}>{translations.endTimeLabel}</AtomicText>
|
|
69
86
|
<AtomicText type="bodyLarge" style={styles.timeValue}>
|
|
70
87
|
{formatTime(config.endHour, config.endMinute)}
|
|
@@ -14,6 +14,18 @@ import { FormButton } from './FormButton';
|
|
|
14
14
|
import { DEFAULT_TIME_PRESETS, FREQUENCY_OPTIONS } from '../../infrastructure/config/reminderPresets';
|
|
15
15
|
import type { Reminder, ReminderFrequency, CreateReminderInput, TimePreset } from '../../../infrastructure/services/types';
|
|
16
16
|
|
|
17
|
+
// Constants for magic numbers
|
|
18
|
+
const DEFAULT_HOUR = 9;
|
|
19
|
+
const DEFAULT_MINUTE = 0;
|
|
20
|
+
const DEFAULT_WEEKDAY = 2; // Tuesday
|
|
21
|
+
const MAX_TITLE_LENGTH = 100;
|
|
22
|
+
const MAX_BODY_LENGTH = 500;
|
|
23
|
+
|
|
24
|
+
// Validation constants
|
|
25
|
+
const VALID_HOUR_RANGE = { min: 0, max: 23 } as const;
|
|
26
|
+
const VALID_MINUTE_RANGE = { min: 0, max: 59 } as const;
|
|
27
|
+
const VALID_WEEKDAY_RANGE = { min: 0, max: 6 } as const;
|
|
28
|
+
|
|
17
29
|
export interface ReminderFormTranslations {
|
|
18
30
|
titleLabel: string;
|
|
19
31
|
titlePlaceholder: string;
|
|
@@ -52,11 +64,24 @@ export const ReminderForm: React.FC<ReminderFormProps> = ({
|
|
|
52
64
|
const [body, setBody] = useState(initialData?.body || '');
|
|
53
65
|
const [frequency, setFrequency] = useState<ReminderFrequency>(initialData?.frequency || 'daily');
|
|
54
66
|
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(initialData?.timePresetId);
|
|
55
|
-
const [hour, setHour] = useState(initialData?.hour ??
|
|
56
|
-
const [minute, setMinute] = useState(initialData?.minute ??
|
|
57
|
-
const [weekday, setWeekday] = useState(initialData?.weekday ??
|
|
67
|
+
const [hour, setHour] = useState(initialData?.hour ?? DEFAULT_HOUR);
|
|
68
|
+
const [minute, setMinute] = useState(initialData?.minute ?? DEFAULT_MINUTE);
|
|
69
|
+
const [weekday, setWeekday] = useState(initialData?.weekday ?? DEFAULT_WEEKDAY);
|
|
58
70
|
const [isCustomTime, setIsCustomTime] = useState(!initialData?.timePresetId);
|
|
59
71
|
|
|
72
|
+
// Validation helper functions
|
|
73
|
+
const isValidHour = useCallback((h: number): boolean => {
|
|
74
|
+
return h >= VALID_HOUR_RANGE.min && h <= VALID_HOUR_RANGE.max;
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const isValidMinute = useCallback((m: number): boolean => {
|
|
78
|
+
return m >= VALID_MINUTE_RANGE.min && m <= VALID_MINUTE_RANGE.max;
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const isValidWeekday = useCallback((w: number): boolean => {
|
|
82
|
+
return w >= VALID_WEEKDAY_RANGE.min && w <= VALID_WEEKDAY_RANGE.max;
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
60
85
|
const handlePresetSelect = useCallback((preset: TimePreset) => {
|
|
61
86
|
setSelectedPresetId(preset.id);
|
|
62
87
|
setHour(preset.hour);
|
|
@@ -70,10 +95,45 @@ export const ReminderForm: React.FC<ReminderFormProps> = ({
|
|
|
70
95
|
}, []);
|
|
71
96
|
|
|
72
97
|
const handleSave = useCallback(() => {
|
|
73
|
-
|
|
98
|
+
const trimmedTitle = title.trim();
|
|
99
|
+
const trimmedBody = body.trim();
|
|
100
|
+
|
|
101
|
+
// Validate title
|
|
102
|
+
if (!trimmedTitle) {
|
|
103
|
+
console.warn('Reminder title is required');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (trimmedTitle.length > MAX_TITLE_LENGTH) {
|
|
108
|
+
console.warn(`Reminder title exceeds maximum length of ${MAX_TITLE_LENGTH}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate body length
|
|
113
|
+
if (trimmedBody.length > MAX_BODY_LENGTH) {
|
|
114
|
+
console.warn(`Reminder body exceeds maximum length of ${MAX_BODY_LENGTH}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate time values
|
|
119
|
+
if (!isValidHour(hour) || !isValidMinute(minute)) {
|
|
120
|
+
console.warn('Invalid time values');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate weekday if frequency is weekly
|
|
125
|
+
if (frequency === 'weekly' && !isValidWeekday(weekday)) {
|
|
126
|
+
console.warn('Invalid weekday value');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sanitize input (React Native handles XSS, but we trim extra whitespace)
|
|
131
|
+
const sanitizedTitle = trimmedTitle.replace(/\s+/g, ' ').trim();
|
|
132
|
+
const sanitizedBody = trimmedBody.replace(/\s+/g, ' ').trim();
|
|
133
|
+
|
|
74
134
|
onSave({
|
|
75
|
-
title:
|
|
76
|
-
body:
|
|
135
|
+
title: sanitizedTitle,
|
|
136
|
+
body: sanitizedBody,
|
|
77
137
|
frequency,
|
|
78
138
|
timePresetId: isCustomTime ? undefined : selectedPresetId,
|
|
79
139
|
hour,
|
|
@@ -81,7 +141,7 @@ export const ReminderForm: React.FC<ReminderFormProps> = ({
|
|
|
81
141
|
weekday: frequency === 'weekly' ? weekday : undefined,
|
|
82
142
|
dayOfMonth: frequency === 'monthly' ? 1 : undefined,
|
|
83
143
|
});
|
|
84
|
-
}, [title, body, frequency, selectedPresetId, hour, minute, weekday, isCustomTime, onSave]);
|
|
144
|
+
}, [title, body, frequency, selectedPresetId, hour, minute, weekday, isCustomTime, onSave, isValidHour, isValidMinute, isValidWeekday]);
|
|
85
145
|
|
|
86
146
|
return (
|
|
87
147
|
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
|
@@ -7,6 +7,23 @@
|
|
|
7
7
|
import { storageRepository, StorageKey, createUserKey } from '@umituz/react-native-design-system';
|
|
8
8
|
import type { ISettingsRepository, UserSettings, SettingsResult } from '../../application/ports/ISettingsRepository';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Validates userId to prevent key injection attacks
|
|
12
|
+
*/
|
|
13
|
+
const validateUserId = (userId: string): void => {
|
|
14
|
+
if (!userId || typeof userId !== 'string') {
|
|
15
|
+
throw new Error('Invalid userId: must be a non-empty string');
|
|
16
|
+
}
|
|
17
|
+
// Prevent potential key injection by restricting characters
|
|
18
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(userId)) {
|
|
19
|
+
throw new Error('Invalid userId: contains invalid characters');
|
|
20
|
+
}
|
|
21
|
+
// Limit length to prevent DoS attacks
|
|
22
|
+
if (userId.length > 128) {
|
|
23
|
+
throw new Error('Invalid userId: exceeds maximum length');
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
10
27
|
export class SettingsRepository implements ISettingsRepository {
|
|
11
28
|
private readonly defaultSettings: (userId: string) => UserSettings = (userId: string) => ({
|
|
12
29
|
userId,
|
|
@@ -24,6 +41,9 @@ export class SettingsRepository implements ISettingsRepository {
|
|
|
24
41
|
|
|
25
42
|
async getSettings(userId: string): Promise<SettingsResult<UserSettings>> {
|
|
26
43
|
try {
|
|
44
|
+
// Validate input
|
|
45
|
+
validateUserId(userId);
|
|
46
|
+
|
|
27
47
|
const storageKey = createUserKey(StorageKey.USER_PREFERENCES, userId);
|
|
28
48
|
const defaults = this.defaultSettings(userId);
|
|
29
49
|
const result = await storageRepository.getItem<UserSettings>(storageKey, defaults);
|
|
@@ -51,6 +71,9 @@ export class SettingsRepository implements ISettingsRepository {
|
|
|
51
71
|
|
|
52
72
|
async saveSettings(settings: UserSettings): Promise<SettingsResult<void>> {
|
|
53
73
|
try {
|
|
74
|
+
// Validate input
|
|
75
|
+
validateUserId(settings.userId);
|
|
76
|
+
|
|
54
77
|
const storageKey = createUserKey(StorageKey.USER_PREFERENCES, settings.userId);
|
|
55
78
|
const result = await storageRepository.setItem(storageKey, settings);
|
|
56
79
|
|
|
@@ -78,6 +101,9 @@ export class SettingsRepository implements ISettingsRepository {
|
|
|
78
101
|
|
|
79
102
|
async deleteSettings(userId: string): Promise<SettingsResult<void>> {
|
|
80
103
|
try {
|
|
104
|
+
// Validate input
|
|
105
|
+
validateUserId(userId);
|
|
106
|
+
|
|
81
107
|
const storageKey = createUserKey(StorageKey.USER_PREFERENCES, userId);
|
|
82
108
|
const result = await storageRepository.removeItem(storageKey);
|
|
83
109
|
|
|
@@ -24,35 +24,47 @@ interface State {
|
|
|
24
24
|
export class SettingsErrorBoundary extends Component<Props, State> {
|
|
25
25
|
constructor(props: Props) {
|
|
26
26
|
super(props);
|
|
27
|
-
|
|
27
|
+
// Initialize state using unknown to avoid type assertion issues
|
|
28
|
+
(this as unknown as { state: State }).state = { hasError: false };
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
static getDerivedStateFromError(error: Error): State {
|
|
31
32
|
return { hasError: true, error };
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
componentDidCatch(_error: Error, _errorInfo: React.ErrorInfo): void {
|
|
36
|
+
// Log error to console in development
|
|
37
|
+
if (__DEV__) {
|
|
38
|
+
console.error('Settings Error Boundary caught an error:', _error);
|
|
39
|
+
console.error('Error Info:', _errorInfo);
|
|
40
|
+
}
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
render(): ReactNode {
|
|
44
|
+
// Safe access through unknown to avoid type assertion issues
|
|
45
|
+
const self = this as unknown as { state: State; props: Props };
|
|
46
|
+
const hasError = self.state.hasError;
|
|
47
|
+
const error = self.state.error;
|
|
48
|
+
const fallback = self.props.fallback;
|
|
49
|
+
const fallbackTitle = self.props.fallbackTitle;
|
|
50
|
+
const fallbackMessage = self.props.fallbackMessage;
|
|
51
|
+
const children = self.props.children;
|
|
52
|
+
|
|
53
|
+
if (hasError) {
|
|
54
|
+
if (fallback) {
|
|
55
|
+
return fallback;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
return (
|
|
47
59
|
<ErrorBoundaryFallback
|
|
48
|
-
error={
|
|
49
|
-
fallbackTitle={
|
|
50
|
-
fallbackMessage={
|
|
60
|
+
error={error}
|
|
61
|
+
fallbackTitle={fallbackTitle}
|
|
62
|
+
fallbackMessage={fallbackMessage}
|
|
51
63
|
/>
|
|
52
64
|
);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
return
|
|
67
|
+
return children;
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
@@ -7,7 +7,7 @@ export interface SettingsSectionProps {
|
|
|
7
7
|
children: React.ReactNode;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
|
10
|
+
export const SettingsSection: React.FC<SettingsSectionProps> = React.memo(({
|
|
11
11
|
title,
|
|
12
12
|
children,
|
|
13
13
|
}) => {
|
|
@@ -23,7 +23,9 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
|
|
23
23
|
{children}
|
|
24
24
|
</View>
|
|
25
25
|
);
|
|
26
|
-
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
SettingsSection.displayName = "SettingsSection";
|
|
27
29
|
|
|
28
30
|
const styles = StyleSheet.create({
|
|
29
31
|
container: {
|
|
@@ -15,13 +15,22 @@ export const useUpdateSettingsMutation = (userId: string) => {
|
|
|
15
15
|
|
|
16
16
|
return useMutation({
|
|
17
17
|
mutationFn: async (updates: Partial<UserSettings>) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Use optimistic updates to prevent race conditions
|
|
19
|
+
// Get current data from query cache first
|
|
20
|
+
const cacheKey = [...SETTINGS_QUERY_KEY, userId];
|
|
21
|
+
const cachedData = queryClient.getQueryData<UserSettings>(cacheKey);
|
|
22
|
+
|
|
23
|
+
const baseSettings = cachedData
|
|
24
|
+
? { ...cachedData }
|
|
25
|
+
: await settingsService.getSettings(userId).then(result => {
|
|
26
|
+
if (!result.success || !result.data) {
|
|
27
|
+
throw new Error('Could not find existing settings to update');
|
|
28
|
+
}
|
|
29
|
+
return result.data;
|
|
30
|
+
});
|
|
22
31
|
|
|
23
32
|
const updatedSettings = {
|
|
24
|
-
...
|
|
33
|
+
...baseSettings,
|
|
25
34
|
...updates,
|
|
26
35
|
updatedAt: new Date(),
|
|
27
36
|
};
|
|
@@ -129,11 +129,17 @@ export const useSettingsScreenConfig = (
|
|
|
129
129
|
|
|
130
130
|
const userProfile = useMemo((): UserProfileDisplay => {
|
|
131
131
|
const isAnonymous = userProfileData?.isAnonymous ?? true;
|
|
132
|
+
|
|
133
|
+
// Ensure t function is available before using it
|
|
134
|
+
const anonymousName = typeof t === 'function'
|
|
135
|
+
? t("settings.profile.anonymousName")
|
|
136
|
+
: 'Anonymous';
|
|
137
|
+
|
|
132
138
|
return {
|
|
133
|
-
displayName: userProfileData?.displayName ||
|
|
134
|
-
userId: userProfileData?.userId,
|
|
139
|
+
displayName: userProfileData?.displayName || anonymousName,
|
|
140
|
+
userId: userProfileData?.userId ?? null,
|
|
135
141
|
isAnonymous,
|
|
136
|
-
avatarUrl: userProfileData?.avatarUrl,
|
|
142
|
+
avatarUrl: userProfileData?.avatarUrl ?? null,
|
|
137
143
|
onPress: isAnonymous ? handleSignIn : undefined,
|
|
138
144
|
accountSettingsRoute: isAnonymous ? undefined : "Account",
|
|
139
145
|
};
|
|
@@ -141,32 +147,37 @@ export const useSettingsScreenConfig = (
|
|
|
141
147
|
|
|
142
148
|
const accountConfig = useMemo((): AccountScreenConfig => {
|
|
143
149
|
const isAnonymous = user?.isAnonymous ?? true;
|
|
150
|
+
|
|
151
|
+
// Ensure t function is available before using it
|
|
152
|
+
const getTranslation = (key: string, fallback: string) =>
|
|
153
|
+
typeof t === 'function' ? t(key) : fallback;
|
|
154
|
+
|
|
144
155
|
return {
|
|
145
156
|
profile: {
|
|
146
|
-
displayName: userProfileData?.displayName || user?.displayName ||
|
|
147
|
-
userId: userProfileData?.userId
|
|
157
|
+
displayName: userProfileData?.displayName || user?.displayName || getTranslation("settings.profile.anonymousName", "Anonymous"),
|
|
158
|
+
userId: userProfileData?.userId ?? user?.uid ?? null,
|
|
148
159
|
isAnonymous,
|
|
149
|
-
avatarUrl: userProfileData?.avatarUrl
|
|
160
|
+
avatarUrl: userProfileData?.avatarUrl ?? user?.photoURL ?? undefined,
|
|
150
161
|
benefits: isAnonymous ? [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
getTranslation("settings.profile.benefits.saveHistory", "Save history"),
|
|
163
|
+
getTranslation("settings.profile.benefits.syncDevices", "Sync devices"),
|
|
164
|
+
getTranslation("settings.profile.benefits.cloudSync", "Cloud sync"),
|
|
165
|
+
getTranslation("settings.profile.benefits.secureBackup", "Secure backup"),
|
|
155
166
|
] : undefined,
|
|
156
167
|
},
|
|
157
168
|
isAnonymous,
|
|
158
|
-
editProfileText:
|
|
169
|
+
editProfileText: getTranslation("settings.account.editProfile", "Edit Profile"),
|
|
159
170
|
onSignIn: handleSignIn,
|
|
160
171
|
accountActions: {
|
|
161
172
|
onLogout: handleSignOut,
|
|
162
173
|
onDeleteAccount: handleDeleteAccount,
|
|
163
|
-
logoutText:
|
|
164
|
-
logoutConfirmTitle:
|
|
165
|
-
logoutConfirmMessage:
|
|
166
|
-
cancelText:
|
|
167
|
-
deleteAccountText:
|
|
168
|
-
deleteConfirmTitle:
|
|
169
|
-
deleteConfirmMessage:
|
|
174
|
+
logoutText: getTranslation("settings.account.logout", "Log Out"),
|
|
175
|
+
logoutConfirmTitle: getTranslation("settings.account.logoutConfirmTitle", "Log Out"),
|
|
176
|
+
logoutConfirmMessage: getTranslation("settings.account.logoutConfirmMessage", "Are you sure you want to log out?"),
|
|
177
|
+
cancelText: getTranslation("common.cancel", "Cancel"),
|
|
178
|
+
deleteAccountText: getTranslation("settings.account.deleteAccount", "Delete Account"),
|
|
179
|
+
deleteConfirmTitle: getTranslation("settings.account.deleteConfirmTitle", "Delete Account"),
|
|
180
|
+
deleteConfirmMessage: getTranslation("settings.account.deleteConfirmMessage", "Are you sure you want to delete your account? This action cannot be undone."),
|
|
170
181
|
},
|
|
171
182
|
};
|
|
172
183
|
}, [user, userProfileData, handleSignIn, handleSignOut, handleDeleteAccount, t]);
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
createQuietHoursTranslations,
|
|
24
24
|
createLegalScreenProps,
|
|
25
25
|
} from "./utils";
|
|
26
|
-
import type { SettingsStackParamList, SettingsStackNavigatorProps } from "./types";
|
|
26
|
+
import type { SettingsStackParamList, SettingsStackNavigatorProps, AdditionalScreen } from "./types";
|
|
27
27
|
import { GamificationScreenWrapper } from "../../domains/gamification";
|
|
28
28
|
|
|
29
29
|
export const SettingsStackNavigator: React.FC<SettingsStackNavigatorProps> = ({
|
|
@@ -74,7 +74,7 @@ export const SettingsStackNavigator: React.FC<SettingsStackNavigatorProps> = ({
|
|
|
74
74
|
);
|
|
75
75
|
|
|
76
76
|
const screens = React.useMemo(() => {
|
|
77
|
-
const
|
|
77
|
+
const baseScreens: StackScreen[] = [
|
|
78
78
|
{
|
|
79
79
|
name: "SettingsMain",
|
|
80
80
|
options: { headerShown: false },
|
|
@@ -119,41 +119,56 @@ export const SettingsStackNavigator: React.FC<SettingsStackNavigatorProps> = ({
|
|
|
119
119
|
},
|
|
120
120
|
];
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
122
|
+
// FAQ screen - conditionally add
|
|
123
|
+
const faqScreen: StackScreen | null = (faqData && faqData.categories.length > 0)
|
|
124
|
+
? {
|
|
125
|
+
name: "FAQ",
|
|
126
|
+
options: { headerTitle: t("settings.faqs.title") },
|
|
127
|
+
children: () => (
|
|
128
|
+
<FAQScreen
|
|
129
|
+
categories={faqData.categories}
|
|
130
|
+
searchPlaceholder={t("settings.faqs.searchPlaceholder")}
|
|
131
|
+
emptySearchTitle={t("settings.faqs.emptySearchTitle")}
|
|
132
|
+
emptySearchMessage={t("settings.faqs.emptySearchMessage")}
|
|
133
|
+
headerTitle={t("settings.faqs.headerTitle")}
|
|
134
|
+
/>
|
|
135
|
+
),
|
|
136
|
+
}
|
|
137
|
+
: null;
|
|
137
138
|
|
|
138
|
-
//
|
|
139
|
-
additionalScreens.
|
|
140
|
-
|
|
139
|
+
// Additional screens - map to StackScreen format
|
|
140
|
+
const additionalStackScreens: StackScreen[] = (additionalScreens as readonly AdditionalScreen[]).map((screen: AdditionalScreen): StackScreen => {
|
|
141
|
+
// Create base screen object
|
|
142
|
+
const stackScreen: Record<string, unknown> = {
|
|
141
143
|
name: screen.name,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Conditionally add properties
|
|
147
|
+
if (screen.component) {
|
|
148
|
+
stackScreen.component = screen.component;
|
|
149
|
+
}
|
|
150
|
+
if (screen.children) {
|
|
151
|
+
stackScreen.children = screen.children;
|
|
152
|
+
}
|
|
153
|
+
if (screen.options) {
|
|
154
|
+
stackScreen.options = screen.options;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Type assertion to StackScreen
|
|
158
|
+
return stackScreen as unknown as StackScreen;
|
|
146
159
|
});
|
|
147
160
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
// Gamification screen - conditionally add
|
|
162
|
+
const gamificationScreen: StackScreen | null = gamificationConfig?.enabled
|
|
163
|
+
? {
|
|
164
|
+
name: "Gamification",
|
|
165
|
+
options: { headerTitle: t("settings.gamification.title") },
|
|
166
|
+
children: () => <GamificationScreenWrapper config={gamificationConfig} />,
|
|
167
|
+
}
|
|
168
|
+
: null;
|
|
155
169
|
|
|
156
|
-
|
|
170
|
+
// Language selection screen
|
|
171
|
+
const languageScreen: StackScreen = {
|
|
157
172
|
name: "LanguageSelection",
|
|
158
173
|
options: { headerShown: false },
|
|
159
174
|
children: () => (
|
|
@@ -162,17 +177,26 @@ export const SettingsStackNavigator: React.FC<SettingsStackNavigatorProps> = ({
|
|
|
162
177
|
searchPlaceholder={t("settings.languageSelection.searchPlaceholder")}
|
|
163
178
|
/>
|
|
164
179
|
),
|
|
165
|
-
}
|
|
180
|
+
};
|
|
166
181
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
182
|
+
// Account screen - conditionally add
|
|
183
|
+
const accountScreen: StackScreen | null = accountConfig
|
|
184
|
+
? {
|
|
185
|
+
name: "Account",
|
|
186
|
+
options: { headerTitle: t("settings.account.title") },
|
|
187
|
+
children: () => <AccountScreen config={accountConfig} />,
|
|
188
|
+
}
|
|
189
|
+
: null;
|
|
174
190
|
|
|
175
|
-
|
|
191
|
+
// Compose final list using spread operator (immutable)
|
|
192
|
+
return [
|
|
193
|
+
...baseScreens,
|
|
194
|
+
...(faqScreen ? [faqScreen] : []),
|
|
195
|
+
...additionalStackScreens,
|
|
196
|
+
...(gamificationScreen ? [gamificationScreen] : []),
|
|
197
|
+
languageScreen,
|
|
198
|
+
...(accountScreen ? [accountScreen] : []),
|
|
199
|
+
];
|
|
176
200
|
}, [
|
|
177
201
|
t,
|
|
178
202
|
showHeader,
|
|
@@ -17,6 +17,7 @@ import type { SettingsConfig, CustomSettingsSection } from "./types";
|
|
|
17
17
|
import type { DevSettingsProps } from "../../domains/dev";
|
|
18
18
|
|
|
19
19
|
export interface SettingsScreenProps {
|
|
20
|
+
children?: React.ReactNode;
|
|
20
21
|
config?: SettingsConfig;
|
|
21
22
|
/** Show user profile header */
|
|
22
23
|
showUserProfile?: boolean;
|
|
@@ -56,6 +57,7 @@ export interface SettingsScreenProps {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|
60
|
+
children,
|
|
59
61
|
config = {},
|
|
60
62
|
showUserProfile,
|
|
61
63
|
userProfile,
|
|
@@ -72,17 +74,46 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|
|
72
74
|
}) => {
|
|
73
75
|
const navigation = useAppNavigation();
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
+
// Memoize config normalization to prevent unnecessary recalculations
|
|
78
|
+
const normalizedConfig = React.useMemo(
|
|
79
|
+
() => normalizeSettingsConfig(config),
|
|
80
|
+
[config]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Memoize feature detection to prevent unnecessary recalculations
|
|
84
|
+
const features = React.useMemo(
|
|
85
|
+
() => useFeatureDetection(normalizedConfig, navigation, featureOptions),
|
|
86
|
+
[normalizedConfig, navigation, featureOptions]
|
|
87
|
+
);
|
|
77
88
|
|
|
78
89
|
// Determine if user profile should be shown (explicit prop takes priority, then config)
|
|
79
90
|
const shouldShowUserProfile = showUserProfile ?? features.userProfile;
|
|
80
91
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
>
|
|
92
|
+
// Workaround: Use conditional rendering with type assertion
|
|
93
|
+
if (showHeader) {
|
|
94
|
+
return <ScreenLayout header={<SettingsHeader showCloseButton={showCloseButton} onClose={onClose} />}>
|
|
85
95
|
<SettingsErrorBoundary>
|
|
96
|
+
{children ?? (
|
|
97
|
+
<SettingsContent
|
|
98
|
+
normalizedConfig={normalizedConfig}
|
|
99
|
+
features={features}
|
|
100
|
+
showUserProfile={shouldShowUserProfile}
|
|
101
|
+
userProfile={userProfile}
|
|
102
|
+
showFooter={showFooter}
|
|
103
|
+
footerText={footerText}
|
|
104
|
+
appVersion={appVersion}
|
|
105
|
+
customSections={customSections}
|
|
106
|
+
devSettings={devSettings}
|
|
107
|
+
gamificationConfig={gamificationConfig}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</SettingsErrorBoundary>
|
|
111
|
+
</ScreenLayout>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return <ScreenLayout>
|
|
115
|
+
<SettingsErrorBoundary>
|
|
116
|
+
{children ?? (
|
|
86
117
|
<SettingsContent
|
|
87
118
|
normalizedConfig={normalizedConfig}
|
|
88
119
|
features={features}
|
|
@@ -95,7 +126,7 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|
|
95
126
|
devSettings={devSettings}
|
|
96
127
|
gamificationConfig={gamificationConfig}
|
|
97
128
|
/>
|
|
98
|
-
|
|
99
|
-
</
|
|
100
|
-
|
|
129
|
+
)}
|
|
130
|
+
</SettingsErrorBoundary>
|
|
131
|
+
</ScreenLayout>;
|
|
101
132
|
};
|
|
@@ -12,7 +12,7 @@ export const CustomSettingsList: React.FC<CustomSettingsListProps> = ({
|
|
|
12
12
|
}) => {
|
|
13
13
|
const sortedSections = useMemo(() => {
|
|
14
14
|
return Array.from(customSections)
|
|
15
|
-
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
15
|
+
.sort((a: CustomSettingsSection, b: CustomSettingsSection) => (a.order ?? 999) - (b.order ?? 999));
|
|
16
16
|
}, [customSections]);
|
|
17
17
|
|
|
18
18
|
if (!sortedSections.length) return null;
|
|
@@ -25,9 +25,9 @@ export const CustomSettingsList: React.FC<CustomSettingsListProps> = ({
|
|
|
25
25
|
title={section.title}
|
|
26
26
|
>
|
|
27
27
|
{section.content}
|
|
28
|
-
{!section.content && section.items
|
|
28
|
+
{!section.content && section.items && section.items.length > 0 && section.items.map((item) => (
|
|
29
29
|
<SettingsItemCard
|
|
30
|
-
key={item.id}
|
|
30
|
+
key={item.id || `item-${index}`}
|
|
31
31
|
title={item.title}
|
|
32
32
|
description={item.subtitle}
|
|
33
33
|
icon={item.icon}
|