@umituz/react-native-settings 4.23.67 → 4.23.69
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 +2 -1
- package/src/domains/about/presentation/hooks/useAboutInfo.ts +0 -1
- package/src/domains/appearance/data/colorPalettes.ts +1 -6
- package/src/domains/appearance/hooks/useAppearance.ts +5 -3
- package/src/domains/appearance/hooks/useAppearanceActions.ts +2 -1
- package/src/domains/appearance/index.ts +2 -1
- package/src/domains/appearance/presentation/components/ColorPicker.tsx +1 -1
- package/src/domains/appearance/presentation/screens/AppearanceScreen.tsx +2 -1
- package/src/domains/appearance/types/index.ts +5 -2
- package/src/domains/disclaimer/presentation/components/DisclaimerModal.tsx +1 -1
- package/src/domains/feedback/presentation/components/FeedbackForm.tsx +48 -12
- package/src/domains/feedback/presentation/components/FeedbackModal.tsx +1 -1
- package/src/domains/gamification/components/GamificationScreen/index.tsx +1 -1
- package/src/domains/gamification/components/GamificationScreenWrapper.tsx +1 -1
- package/src/domains/gamification/utils/calculations.ts +1 -1
- package/src/domains/legal/presentation/components/LegalItem.tsx +1 -1
- package/src/domains/localization/index.ts +0 -2
- package/src/domains/localization/infrastructure/components/useLanguageSwitcher.ts +18 -1
- package/src/domains/localization/infrastructure/config/languages.ts +0 -9
- package/src/domains/localization/infrastructure/hooks/TranslationHook.ts +22 -24
- package/src/domains/localization/infrastructure/storage/LocalizationStore.ts +163 -111
- package/src/domains/notifications/infrastructure/services/NotificationManager.ts +1 -3
- package/src/domains/notifications/infrastructure/services/NotificationScheduler.ts +2 -2
- package/src/domains/notifications/quietHours/presentation/components/QuietHoursCard.tsx +0 -1
- package/src/domains/notifications/reminders/infrastructure/hooks/useReminderActions.ts +60 -25
- package/src/domains/notifications/reminders/presentation/screens/ReminderListScreen.tsx +2 -2
- package/src/domains/rating/presentation/components/RatingPromptModal.tsx +1 -1
- package/src/domains/rating/presentation/components/StarRating.tsx +1 -1
- package/src/presentation/components/SettingsErrorBoundary.tsx +14 -18
- package/src/presentation/components/SettingsFooter.tsx +1 -3
- package/src/presentation/components/SettingsItemCard.tsx +27 -6
- package/src/presentation/hooks/queries/useSettingsQuery.ts +0 -1
- package/src/presentation/hooks/useSettings.ts +8 -0
- package/src/presentation/hooks/useSettingsScreenConfig.ts +3 -3
- package/src/presentation/screens/SettingsScreen.tsx +6 -3
- package/src/presentation/screens/components/SettingsContent.tsx +16 -1
- package/src/domains/appearance/hooks/index.ts +0 -6
- package/src/domains/appearance/presentation/screens/index.ts +0 -2
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.69",
|
|
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",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"@umituz/react-native-sentry": "*",
|
|
82
82
|
"eslint": "^8.57.0",
|
|
83
83
|
"eslint-plugin-react": "^7.37.5",
|
|
84
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
84
85
|
"eslint-plugin-react-native": "^5.0.0",
|
|
85
86
|
"expo-apple-authentication": "^8.0.8",
|
|
86
87
|
"expo-auth-session": "^5.0.0",
|
|
@@ -9,7 +9,6 @@ import { AboutRepository } from '../../infrastructure/repositories/AboutReposito
|
|
|
9
9
|
import type { UseAboutInfoOptions, UseAboutInfoReturn } from './useAboutInfo.types';
|
|
10
10
|
import {
|
|
11
11
|
setErrorIfMounted,
|
|
12
|
-
setLoadingIfMounted,
|
|
13
12
|
initializeAppInfo,
|
|
14
13
|
updateAppInfoConfig,
|
|
15
14
|
updateAppInfoPartial,
|
|
@@ -86,9 +86,4 @@ export const generateColorPalette = (
|
|
|
86
86
|
name: "custom",
|
|
87
87
|
colors,
|
|
88
88
|
};
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// Legacy exports for backward compatibility
|
|
92
|
-
export const PRIMARY_COLORS = DEFAULT_PRIMARY_COLORS.colors;
|
|
93
|
-
export const SECONDARY_COLORS = DEFAULT_SECONDARY_COLORS.colors;
|
|
94
|
-
export const ACCENT_COLORS = DEFAULT_ACCENT_COLORS.colors;
|
|
89
|
+
};
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { useTheme, type ThemeMode, type CustomThemeColors } from "@umituz/react-native-design-system";
|
|
1
|
+
import { useTheme, type ThemeMode as BaseThemeMode, type CustomThemeColors } from "@umituz/react-native-design-system";
|
|
2
|
+
import type { ThemeMode } from "../types";
|
|
2
3
|
|
|
3
4
|
export const useAppearance = () => {
|
|
4
5
|
const { themeMode, customColors, defaultColors, isInitialized, setThemeMode, setCustomColors, resetToDefaults } = useTheme();
|
|
5
6
|
|
|
6
7
|
return {
|
|
7
|
-
themeMode,
|
|
8
|
+
themeMode: themeMode as ThemeMode,
|
|
8
9
|
customColors,
|
|
9
10
|
defaultColors,
|
|
10
11
|
isLoading: !isInitialized,
|
|
11
12
|
setThemeMode: (mode: ThemeMode) => {
|
|
12
|
-
|
|
13
|
+
// Cast to base ThemeMode since design system doesn't support 'auto'
|
|
14
|
+
void setThemeMode(mode as BaseThemeMode);
|
|
13
15
|
},
|
|
14
16
|
setCustomColors: (colors: CustomThemeColors) => {
|
|
15
17
|
void setCustomColors(colors);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { useAppearance } from "./useAppearance";
|
|
3
|
-
import type { CustomThemeColors
|
|
3
|
+
import type { CustomThemeColors } from "@umituz/react-native-design-system";
|
|
4
|
+
import type { ThemeMode } from "../types";
|
|
4
5
|
|
|
5
6
|
export const useAppearanceActions = () => {
|
|
6
7
|
const { themeMode, customColors, setThemeMode, setCustomColors, reset } = useAppearance();
|
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
export * from './presentation/screens/AppearanceScreen';
|
|
7
7
|
export * from './presentation/components';
|
|
8
|
-
export
|
|
8
|
+
export { useAppearance } from './hooks/useAppearance';
|
|
9
|
+
export { useAppearanceActions } from './hooks/useAppearanceActions';
|
|
9
10
|
export * from './types';
|
|
@@ -49,7 +49,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
49
49
|
|
|
50
50
|
// Memoize color options to prevent unnecessary re-renders
|
|
51
51
|
const colorOptions = useMemo(() => {
|
|
52
|
-
return colorsMemo.map((color
|
|
52
|
+
return colorsMemo.map((color) => {
|
|
53
53
|
const isSelected = value === color;
|
|
54
54
|
|
|
55
55
|
return (
|
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
useAppNavigation
|
|
14
14
|
} from "@umituz/react-native-design-system";
|
|
15
15
|
import { useLocalization } from "../../../localization";
|
|
16
|
-
import { useAppearance
|
|
16
|
+
import { useAppearance } from "../../hooks/useAppearance";
|
|
17
|
+
import { useAppearanceActions } from "../../hooks/useAppearanceActions";
|
|
17
18
|
import {
|
|
18
19
|
AppearanceHeader,
|
|
19
20
|
ThemeModeSection,
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
-
ThemeMode,
|
|
8
|
+
ThemeMode as BaseThemeMode,
|
|
9
9
|
CustomThemeColors,
|
|
10
10
|
} from "@umituz/react-native-design-system";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Extended theme mode to support 'auto' option
|
|
13
|
+
export type ThemeMode = BaseThemeMode | 'auto';
|
|
14
|
+
|
|
15
|
+
export type { CustomThemeColors };
|
|
13
16
|
|
|
14
17
|
export interface AppearanceSettings {
|
|
15
18
|
themeMode: ThemeMode;
|
|
@@ -69,7 +69,7 @@ export const DisclaimerModal: React.FC<DisclaimerModalProps> = ({
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
const getStyles = (
|
|
72
|
+
const getStyles = (_tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
73
73
|
StyleSheet.create({
|
|
74
74
|
modalContainer: {
|
|
75
75
|
flex: 1,
|
|
@@ -7,7 +7,6 @@ import React, { useState } from "react";
|
|
|
7
7
|
import { View, StyleSheet, TouchableOpacity, ScrollView, TextInput } from "react-native";
|
|
8
8
|
import { useAppDesignTokens, AtomicText, AtomicButton, AtomicIcon } from "@umituz/react-native-design-system";
|
|
9
9
|
import type { FeedbackType, FeedbackRating } from "../../domain/entities/FeedbackEntity";
|
|
10
|
-
import { useFeedbackForm } from "../hooks/useFeedbackForm";
|
|
11
10
|
|
|
12
11
|
export interface FeedbackFormProps {
|
|
13
12
|
onSubmit: (data: { type: FeedbackType; rating: FeedbackRating; description: string; title: string }) => Promise<void>;
|
|
@@ -35,16 +34,38 @@ export const FeedbackForm: React.FC<FeedbackFormProps> = ({
|
|
|
35
34
|
const [rating, setRating] = useState<FeedbackRating>(5);
|
|
36
35
|
const [description, setDescription] = useState("");
|
|
37
36
|
const [title, setTitle] = useState("");
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
const [isSubmittingLocal, setIsSubmittingLocal] = useState(false);
|
|
38
39
|
|
|
39
40
|
const handleSubmit = async () => {
|
|
40
|
-
|
|
41
|
+
// Validate input
|
|
42
|
+
if (!description.trim()) {
|
|
43
|
+
setError("Please provide a description");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
setIsSubmittingLocal(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await onSubmit({
|
|
52
|
+
type: selectedType,
|
|
53
|
+
rating,
|
|
54
|
+
description,
|
|
55
|
+
title: title || texts.defaultTitle(selectedType),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Clear form on success
|
|
59
|
+
setDescription("");
|
|
60
|
+
setTitle("");
|
|
61
|
+
setRating(5);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to submit feedback";
|
|
64
|
+
setError(errorMessage);
|
|
65
|
+
console.error("[FeedbackForm] Submission error:", err);
|
|
66
|
+
} finally {
|
|
67
|
+
setIsSubmittingLocal(false);
|
|
68
|
+
}
|
|
48
69
|
};
|
|
49
70
|
|
|
50
71
|
const renderRating = () => (
|
|
@@ -116,7 +137,10 @@ export const FeedbackForm: React.FC<FeedbackFormProps> = ({
|
|
|
116
137
|
<View style={styles.inputContainer}>
|
|
117
138
|
<TextInput
|
|
118
139
|
value={description}
|
|
119
|
-
onChangeText={
|
|
140
|
+
onChangeText={(text) => {
|
|
141
|
+
setDescription(text);
|
|
142
|
+
setError(null); // Clear error on input
|
|
143
|
+
}}
|
|
120
144
|
placeholder={texts.descriptionPlaceholder}
|
|
121
145
|
placeholderTextColor={tokens.colors.textTertiary}
|
|
122
146
|
multiline
|
|
@@ -126,18 +150,27 @@ export const FeedbackForm: React.FC<FeedbackFormProps> = ({
|
|
|
126
150
|
{
|
|
127
151
|
color: tokens.colors.textPrimary,
|
|
128
152
|
backgroundColor: tokens.colors.surface,
|
|
129
|
-
borderColor: tokens.colors.border,
|
|
153
|
+
borderColor: error ? tokens.colors.error : tokens.colors.border,
|
|
130
154
|
}
|
|
131
155
|
]}
|
|
132
156
|
/>
|
|
157
|
+
{error && (
|
|
158
|
+
<AtomicText
|
|
159
|
+
type="bodySmall"
|
|
160
|
+
color="error"
|
|
161
|
+
style={styles.errorText}
|
|
162
|
+
>
|
|
163
|
+
{error}
|
|
164
|
+
</AtomicText>
|
|
165
|
+
)}
|
|
133
166
|
</View>
|
|
134
167
|
|
|
135
168
|
<AtomicButton
|
|
136
169
|
onPress={handleSubmit}
|
|
137
|
-
disabled={isSubmitting || !description.trim()}
|
|
170
|
+
disabled={isSubmitting || isSubmittingLocal || !description.trim()}
|
|
138
171
|
style={styles.submitButton}
|
|
139
172
|
>
|
|
140
|
-
{isSubmitting ? texts.submittingButton : texts.submitButton}
|
|
173
|
+
{(isSubmitting || isSubmittingLocal) ? texts.submittingButton : texts.submitButton}
|
|
141
174
|
</AtomicButton>
|
|
142
175
|
</View>
|
|
143
176
|
);
|
|
@@ -185,6 +218,9 @@ const getStyles = (tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
|
185
218
|
padding: 12,
|
|
186
219
|
fontSize: tokens.typography.bodyMedium.fontSize,
|
|
187
220
|
},
|
|
221
|
+
errorText: {
|
|
222
|
+
marginTop: 8,
|
|
223
|
+
},
|
|
188
224
|
submitButton: {
|
|
189
225
|
width: "100%",
|
|
190
226
|
},
|
|
@@ -79,7 +79,7 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
const getStyles = (
|
|
82
|
+
const getStyles = (_tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
83
83
|
StyleSheet.create({
|
|
84
84
|
safeArea: {
|
|
85
85
|
flex: 1,
|
|
@@ -5,7 +5,7 @@ import React from "react";
|
|
|
5
5
|
* Generic for 100+ apps - NO hardcoded strings
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { View, ScrollView
|
|
8
|
+
import { View, ScrollView } from "react-native";
|
|
9
9
|
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
10
10
|
import { LevelProgress } from "../LevelProgress";
|
|
11
11
|
import { StreakDisplay } from "../StreakDisplay";
|
|
@@ -11,7 +11,7 @@ interface GamificationScreenWrapperProps {
|
|
|
11
11
|
export const GamificationScreenWrapper: React.FC<GamificationScreenWrapperProps> = ({ config }) => {
|
|
12
12
|
const {
|
|
13
13
|
points,
|
|
14
|
-
totalTasksCompleted,
|
|
14
|
+
totalTasksCompleted: _totalTasksCompleted,
|
|
15
15
|
level,
|
|
16
16
|
streak,
|
|
17
17
|
achievements,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Pure utility functions - NO side effects
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { LevelDefinition, LevelState,
|
|
6
|
+
import type { LevelDefinition, LevelState, AchievementDefinition } from "../types";
|
|
7
7
|
|
|
8
8
|
export const calculateLevel = (
|
|
9
9
|
points: number,
|
|
@@ -42,7 +42,7 @@ export const LegalItem: React.FC<LegalItemProps> = React.memo(({
|
|
|
42
42
|
title,
|
|
43
43
|
description,
|
|
44
44
|
onPress,
|
|
45
|
-
testID,
|
|
45
|
+
testID: _testID,
|
|
46
46
|
}) => {
|
|
47
47
|
// Use iconName if provided, otherwise fallback to default
|
|
48
48
|
const finalIcon = iconName || icon || "shield-checkmark";
|
|
@@ -22,8 +22,6 @@ export type { LanguageSectionProps, LanguageSectionConfig } from './presentation
|
|
|
22
22
|
export { default as i18n } from './infrastructure/config/i18n';
|
|
23
23
|
export { I18nInitializer } from './infrastructure/config/I18nInitializer';
|
|
24
24
|
export {
|
|
25
|
-
SUPPORTED_LANGUAGES,
|
|
26
|
-
LANGUAGES,
|
|
27
25
|
DEFAULT_LANGUAGE,
|
|
28
26
|
getLanguageByCode,
|
|
29
27
|
isLanguageSupported,
|
|
@@ -17,7 +17,24 @@ export const useLanguageSwitcher = ({ onPress, disabled }: UseLanguageSwitcherPr
|
|
|
17
17
|
const { currentLanguage } = useLocalization();
|
|
18
18
|
|
|
19
19
|
const currentLang = useMemo((): Language => {
|
|
20
|
-
|
|
20
|
+
// Double fallback to ensure we always have a valid language
|
|
21
|
+
const lang = languageRepository.getLanguageByCode(currentLanguage)
|
|
22
|
+
|| languageRepository.getDefaultLanguage()
|
|
23
|
+
|| languageRepository.getLanguages()[0]; // Final fallback
|
|
24
|
+
|
|
25
|
+
if (!lang) {
|
|
26
|
+
// This should never happen if repository is set up correctly
|
|
27
|
+
console.error('[useLanguageSwitcher] No valid language found. Check language repository configuration.');
|
|
28
|
+
// Return a minimal fallback language object
|
|
29
|
+
return {
|
|
30
|
+
code: 'en',
|
|
31
|
+
name: 'English',
|
|
32
|
+
nativeName: 'English',
|
|
33
|
+
flag: '🇺🇸',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return lang;
|
|
21
38
|
}, [currentLanguage]);
|
|
22
39
|
|
|
23
40
|
const handlePress = useCallback(() => {
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
* Central export point for all language-related functionality
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { languageRepository } from '../repository/LanguageRepository';
|
|
7
|
-
import type { Language } from '../storage/types/Language';
|
|
8
|
-
|
|
9
6
|
// Re-export from DeviceLocale
|
|
10
7
|
export { DEFAULT_LANGUAGE, getDeviceLocale } from './DeviceLocale';
|
|
11
8
|
|
|
@@ -20,9 +17,3 @@ export {
|
|
|
20
17
|
|
|
21
18
|
// Re-export from LocaleMapping
|
|
22
19
|
export { LOCALE_MAPPING } from './LocaleMapping';
|
|
23
|
-
|
|
24
|
-
// Backward compatibility
|
|
25
|
-
export const getSUPPORTED_LANGUAGES = () => languageRepository.getLanguages();
|
|
26
|
-
export const getLANGUAGES = () => languageRepository.getLanguages();
|
|
27
|
-
export const SUPPORTED_LANGUAGES: Language[] = languageRepository.getLanguages();
|
|
28
|
-
export const LANGUAGES = SUPPORTED_LANGUAGES;
|
|
@@ -10,30 +10,28 @@
|
|
|
10
10
|
import { useTranslation } from 'react-i18next';
|
|
11
11
|
import i18n from '../config/i18n';
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const translationResult = useTranslation(undefined, { i18n });
|
|
13
|
+
/**
|
|
14
|
+
* Custom hook that provides translation function with proper fallbacks
|
|
15
|
+
*/
|
|
16
|
+
export const useTranslationFunction = (): ((key: string, options?: Record<string, unknown>) => string) => {
|
|
17
|
+
// Always call useTranslation hook (React hooks rules)
|
|
18
|
+
const translationResult = useTranslation(undefined, { i18n });
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
// Use react-i18next if available, otherwise fallback to direct i18n
|
|
21
|
+
if (translationResult?.t && typeof translationResult.t === 'function' && i18n.isInitialized) {
|
|
22
|
+
return (key: string, options?: Record<string, unknown>): string => {
|
|
23
|
+
const result = translationResult.t(key, options);
|
|
24
|
+
return typeof result === 'string' ? result : String(result);
|
|
25
|
+
};
|
|
26
|
+
} else {
|
|
27
|
+
return (key: string, options?: Record<string, unknown>): string => {
|
|
28
|
+
// Fallback to direct i18n.t
|
|
29
|
+
if (i18n.isInitialized && typeof i18n.t === 'function') {
|
|
30
|
+
const result = i18n.t(key, options);
|
|
25
31
|
return typeof result === 'string' ? result : String(result);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
if (i18n.isInitialized && typeof i18n.t === 'function') {
|
|
31
|
-
const result = i18n.t(key, options);
|
|
32
|
-
return typeof result === 'string' ? result : String(result);
|
|
33
|
-
}
|
|
34
|
-
// Final fallback: return key
|
|
35
|
-
return key;
|
|
36
|
-
};
|
|
37
|
-
}
|
|
32
|
+
}
|
|
33
|
+
// Final fallback: return key
|
|
34
|
+
return key;
|
|
35
|
+
};
|
|
38
36
|
}
|
|
39
|
-
}
|
|
37
|
+
};
|
|
@@ -13,130 +13,182 @@ declare const __DEV__: boolean;
|
|
|
13
13
|
|
|
14
14
|
type LocalizationStoreType = LocalizationState & LocalizationActions & LocalizationGetters;
|
|
15
15
|
|
|
16
|
-
// Mutex to prevent race condition in initialize
|
|
17
|
-
let initializeInProgress = false;
|
|
18
|
-
// Debounce timer for language switching
|
|
19
|
-
let languageSwitchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
20
16
|
const LANGUAGE_SWITCH_DEBOUNCE_MS = 300;
|
|
21
|
-
// Track pending promise resolvers to ensure all get resolved
|
|
22
|
-
let pendingResolvers: Array<() => void> = [];
|
|
23
|
-
|
|
24
|
-
export const useLocalizationStore = create<LocalizationStoreType>((set, get) => ({
|
|
25
|
-
// State
|
|
26
|
-
currentLanguage: 'en-US',
|
|
27
|
-
isRTL: false,
|
|
28
|
-
isInitialized: false,
|
|
29
|
-
supportedLanguages: languageRepository.getLanguages(),
|
|
30
|
-
|
|
31
|
-
// Actions
|
|
32
|
-
initialize: async () => {
|
|
33
|
-
const { isInitialized: alreadyInitialized } = get();
|
|
34
|
-
|
|
35
|
-
// Atomic check: both state flag AND in-progress mutex
|
|
36
|
-
if (alreadyInitialized || initializeInProgress) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Set mutex immediately (synchronous)
|
|
41
|
-
initializeInProgress = true;
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const result = await LanguageInitializer.initialize();
|
|
45
|
-
|
|
46
|
-
set({
|
|
47
|
-
currentLanguage: result.languageCode,
|
|
48
|
-
isRTL: result.isRTL,
|
|
49
|
-
isInitialized: true,
|
|
50
|
-
});
|
|
51
|
-
} catch {
|
|
52
|
-
set({
|
|
53
|
-
currentLanguage: 'en-US',
|
|
54
|
-
isRTL: false,
|
|
55
|
-
isInitialized: true,
|
|
56
|
-
});
|
|
57
|
-
} finally {
|
|
58
|
-
// Reset mutex after completion (success or failure)
|
|
59
|
-
initializeInProgress = false;
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
setLanguage: async (languageCode: string) => {
|
|
64
|
-
// Debounce rapid language switches
|
|
65
|
-
if (languageSwitchTimer) {
|
|
66
|
-
clearTimeout(languageSwitchTimer);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return new Promise<void>((resolve) => {
|
|
70
|
-
// Add this resolver to pending list
|
|
71
|
-
pendingResolvers.push(resolve);
|
|
72
|
-
|
|
73
|
-
languageSwitchTimer = setTimeout(async () => {
|
|
74
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
75
|
-
console.log('[LocalizationStore] setLanguage called:', languageCode);
|
|
76
|
-
}
|
|
77
17
|
|
|
18
|
+
export const useLocalizationStore = create<LocalizationStoreType>((set, get) => {
|
|
19
|
+
// Instance-level state to prevent memory leaks
|
|
20
|
+
let initializeInProgress = false;
|
|
21
|
+
let initializePromise: Promise<void> | null = null;
|
|
22
|
+
let languageSwitchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
const pendingResolvers: Array<() => void> = [];
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
// State
|
|
27
|
+
currentLanguage: 'en-US',
|
|
28
|
+
isRTL: false,
|
|
29
|
+
isInitialized: false,
|
|
30
|
+
supportedLanguages: languageRepository.getLanguages(),
|
|
31
|
+
|
|
32
|
+
// Actions
|
|
33
|
+
initialize: async () => {
|
|
34
|
+
const { isInitialized: alreadyInitialized } = get();
|
|
35
|
+
|
|
36
|
+
// Return existing promise if initialization is in progress
|
|
37
|
+
if (initializeInProgress && initializePromise) {
|
|
38
|
+
return initializePromise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return if already initialized
|
|
42
|
+
if (alreadyInitialized) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Set mutex and create promise
|
|
47
|
+
initializeInProgress = true;
|
|
48
|
+
initializePromise = (async () => {
|
|
78
49
|
try {
|
|
79
|
-
const result = await
|
|
50
|
+
const result = await LanguageInitializer.initialize();
|
|
80
51
|
|
|
52
|
+
set({
|
|
53
|
+
currentLanguage: result.languageCode,
|
|
54
|
+
isRTL: result.isRTL,
|
|
55
|
+
isInitialized: true,
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Log and set fallback state
|
|
81
59
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
82
|
-
console.
|
|
60
|
+
console.error('[LocalizationStore] Initialization failed:', error);
|
|
83
61
|
}
|
|
84
62
|
|
|
85
63
|
set({
|
|
86
|
-
currentLanguage:
|
|
87
|
-
isRTL:
|
|
64
|
+
currentLanguage: 'en-US',
|
|
65
|
+
isRTL: false,
|
|
66
|
+
isInitialized: true,
|
|
88
67
|
});
|
|
89
68
|
|
|
90
|
-
|
|
91
|
-
|
|
69
|
+
throw error; // Re-throw to allow error handling
|
|
70
|
+
} finally {
|
|
71
|
+
initializeInProgress = false;
|
|
72
|
+
initializePromise = null;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
return initializePromise;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
setLanguage: async (languageCode: string) => {
|
|
80
|
+
// Validate input
|
|
81
|
+
if (!languageCode || typeof languageCode !== 'string') {
|
|
82
|
+
throw new Error('Invalid language code provided');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clear existing timer
|
|
86
|
+
if (languageSwitchTimer) {
|
|
87
|
+
clearTimeout(languageSwitchTimer);
|
|
88
|
+
languageSwitchTimer = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Promise<void>((resolve, reject) => {
|
|
92
|
+
// Add resolver to pending list
|
|
93
|
+
pendingResolvers.push(() => {
|
|
94
|
+
// Resolve successfully
|
|
95
|
+
resolve();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Create rejection handler
|
|
99
|
+
const rejectAndCleanup = (error: Error) => {
|
|
100
|
+
// Remove this resolver
|
|
101
|
+
const index = pendingResolvers.findIndex(r => r === resolve);
|
|
102
|
+
if (index > -1) {
|
|
103
|
+
pendingResolvers.splice(index, 1);
|
|
92
104
|
}
|
|
93
|
-
|
|
105
|
+
reject(error);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
languageSwitchTimer = setTimeout(async () => {
|
|
94
109
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
95
|
-
console.
|
|
110
|
+
console.log('[LocalizationStore] setLanguage called:', languageCode);
|
|
96
111
|
}
|
|
97
|
-
}
|
|
98
112
|
|
|
113
|
+
try {
|
|
114
|
+
const result = await LanguageSwitcher.switchLanguage(languageCode);
|
|
115
|
+
|
|
116
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
117
|
+
console.log('[LocalizationStore] LanguageSwitcher result:', result);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
set({
|
|
121
|
+
currentLanguage: result.languageCode,
|
|
122
|
+
isRTL: result.isRTL,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
126
|
+
console.log('[LocalizationStore] Store updated with new language:', result.languageCode);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resolve ALL pending promises
|
|
130
|
+
const resolvers = [...pendingResolvers];
|
|
131
|
+
pendingResolvers.length = 0; // Clear array
|
|
132
|
+
resolvers.forEach(r => r());
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const errorMessage = error instanceof Error ? error : new Error(String(error));
|
|
135
|
+
|
|
136
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
137
|
+
console.error('[LocalizationStore] Language switch failed:', error);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Reject all pending promises
|
|
141
|
+
const resolvers = [...pendingResolvers];
|
|
142
|
+
pendingResolvers.length = 0; // Clear array
|
|
143
|
+
resolvers.forEach(() => {
|
|
144
|
+
// Each resolver is wrapped to handle rejection
|
|
145
|
+
// Note: We can't reject promises already created, so we just clear them
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Reject this specific promise
|
|
149
|
+
rejectAndCleanup(errorMessage);
|
|
150
|
+
} finally {
|
|
151
|
+
languageSwitchTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}, LANGUAGE_SWITCH_DEBOUNCE_MS);
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
reset: () => {
|
|
158
|
+
// Clear any pending language switch
|
|
159
|
+
if (languageSwitchTimer) {
|
|
160
|
+
clearTimeout(languageSwitchTimer);
|
|
99
161
|
languageSwitchTimer = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve any pending promises to prevent hanging
|
|
165
|
+
const resolvers = [...pendingResolvers];
|
|
166
|
+
pendingResolvers.length = 0; // Clear array
|
|
167
|
+
resolvers.forEach(r => r());
|
|
168
|
+
|
|
169
|
+
// Reset mutex
|
|
170
|
+
initializeInProgress = false;
|
|
171
|
+
initializePromise = null;
|
|
100
172
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
currentLanguage: 'en-US',
|
|
124
|
-
isRTL: false,
|
|
125
|
-
isInitialized: false,
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
// Getters
|
|
130
|
-
getCurrentLanguage: () => {
|
|
131
|
-
const { currentLanguage } = get();
|
|
132
|
-
return languageRepository.getLanguageByCode(currentLanguage);
|
|
133
|
-
},
|
|
134
|
-
|
|
135
|
-
isLanguageSupported: (code: string) => {
|
|
136
|
-
return languageRepository.isLanguageSupported(code);
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
getSupportedLanguages: () => {
|
|
140
|
-
return languageRepository.getLanguages();
|
|
141
|
-
},
|
|
142
|
-
}));
|
|
173
|
+
set({
|
|
174
|
+
currentLanguage: 'en-US',
|
|
175
|
+
isRTL: false,
|
|
176
|
+
isInitialized: false,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
// Getters
|
|
181
|
+
getCurrentLanguage: () => {
|
|
182
|
+
const { currentLanguage } = get();
|
|
183
|
+
return languageRepository.getLanguageByCode(currentLanguage);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
isLanguageSupported: (code: string) => {
|
|
187
|
+
return languageRepository.isLanguageSupported(code);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
getSupportedLanguages: () => {
|
|
191
|
+
return languageRepository.getLanguages();
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
});
|
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as Notifications from 'expo-notifications';
|
|
9
|
-
import * as Device from 'expo-device';
|
|
10
|
-
import { Platform } from 'react-native';
|
|
11
9
|
import { NotificationPermissions } from './NotificationPermissions';
|
|
12
10
|
import { NotificationScheduler } from './NotificationScheduler';
|
|
13
11
|
import { NotificationBadgeManager } from './NotificationBadgeManager';
|
|
14
12
|
import { devLog, devError } from '../utils/dev';
|
|
15
|
-
import type {
|
|
13
|
+
import type { ScheduleNotificationOptions, ScheduledNotification } from './types';
|
|
16
14
|
|
|
17
15
|
export class NotificationManager {
|
|
18
16
|
private permissions: NotificationPermissions;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as Notifications from 'expo-notifications';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ScheduleNotificationOptions, ScheduledNotification } from './types';
|
|
3
3
|
|
|
4
4
|
export class NotificationScheduler {
|
|
5
5
|
async scheduleNotification(options: ScheduleNotificationOptions): Promise<string> {
|
|
@@ -69,7 +69,7 @@ export class NotificationScheduler {
|
|
|
69
69
|
content: {
|
|
70
70
|
title: notification.content.title ?? '',
|
|
71
71
|
body: notification.content.body ?? '',
|
|
72
|
-
data: notification.content.data as Record<string,
|
|
72
|
+
data: notification.content.data as Record<string, string | number | boolean | null>,
|
|
73
73
|
},
|
|
74
74
|
trigger: notification.trigger,
|
|
75
75
|
}));
|
|
@@ -8,7 +8,6 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
|
|
8
8
|
import { AtomicText, AtomicIcon, AtomicCard } from '@umituz/react-native-design-system';
|
|
9
9
|
import { Switch } from 'react-native';
|
|
10
10
|
import { useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
11
|
-
import { SettingRow } from '../../../presentation/components/SettingRow';
|
|
12
11
|
import type { QuietHoursConfig, QuietHoursTranslations } from '../../../infrastructure/services/types';
|
|
13
12
|
|
|
14
13
|
export interface QuietHoursCardProps {
|
|
@@ -13,7 +13,7 @@ import type { Reminder, CreateReminderInput, UpdateReminderInput } from '../../.
|
|
|
13
13
|
const scheduler = new NotificationScheduler();
|
|
14
14
|
|
|
15
15
|
export const useReminderActions = () => {
|
|
16
|
-
const { addReminder, updateReminder, deleteReminder, toggleReminder } = useRemindersStore();
|
|
16
|
+
const { addReminder, updateReminder, deleteReminder, toggleReminder: _toggleReminder } = useRemindersStore();
|
|
17
17
|
|
|
18
18
|
const createReminder = useCallback(async (input: CreateReminderInput): Promise<Reminder> => {
|
|
19
19
|
const now = new Date().toISOString();
|
|
@@ -34,7 +34,9 @@ export const useReminderActions = () => {
|
|
|
34
34
|
data: { reminderId: reminder.id },
|
|
35
35
|
});
|
|
36
36
|
reminder.notificationId = notificationId;
|
|
37
|
-
} catch {
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// Log error for debugging
|
|
39
|
+
console.error('[useReminderActions] Failed to schedule notification:', error);
|
|
38
40
|
reminder.enabled = false;
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -44,25 +46,39 @@ export const useReminderActions = () => {
|
|
|
44
46
|
}, [addReminder]);
|
|
45
47
|
|
|
46
48
|
const editReminder = useCallback(async (id: string, input: UpdateReminderInput): Promise<void> => {
|
|
47
|
-
|
|
48
|
-
const existing = reminders.find(r => r.id === id);
|
|
49
|
-
|
|
49
|
+
// Get current state BEFORE async operations to prevent race condition
|
|
50
|
+
const existing = useRemindersStore.getState().reminders.find(r => r.id === id);
|
|
51
|
+
|
|
52
|
+
if (!existing) {
|
|
53
|
+
throw new Error(`Reminder with id ${id} not found`);
|
|
54
|
+
}
|
|
50
55
|
|
|
51
56
|
if (existing.notificationId) {
|
|
52
|
-
|
|
57
|
+
try {
|
|
58
|
+
await scheduler.cancelNotification(existing.notificationId);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('[useReminderActions] Failed to cancel notification:', error);
|
|
61
|
+
// Continue with update even if cancellation fails
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
const updated: Reminder = { ...existing, ...input, updatedAt: new Date().toISOString() };
|
|
56
66
|
|
|
57
67
|
if (updated.enabled) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
try {
|
|
69
|
+
const trigger = buildTrigger(updated);
|
|
70
|
+
const notificationId = await scheduler.scheduleNotification({
|
|
71
|
+
title: updated.title,
|
|
72
|
+
body: updated.body,
|
|
73
|
+
trigger,
|
|
74
|
+
data: { reminderId: updated.id },
|
|
75
|
+
});
|
|
76
|
+
updated.notificationId = notificationId;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('[useReminderActions] Failed to schedule notification:', error);
|
|
79
|
+
updated.enabled = false;
|
|
80
|
+
updated.notificationId = undefined;
|
|
81
|
+
}
|
|
66
82
|
} else {
|
|
67
83
|
updated.notificationId = undefined;
|
|
68
84
|
}
|
|
@@ -71,24 +87,41 @@ export const useReminderActions = () => {
|
|
|
71
87
|
}, [updateReminder]);
|
|
72
88
|
|
|
73
89
|
const removeReminder = useCallback(async (id: string): Promise<void> => {
|
|
74
|
-
|
|
75
|
-
const reminder = reminders.find(r => r.id === id);
|
|
90
|
+
// Get current state BEFORE async operations to prevent race condition
|
|
91
|
+
const reminder = useRemindersStore.getState().reminders.find(r => r.id === id);
|
|
76
92
|
|
|
77
|
-
if (reminder
|
|
78
|
-
|
|
93
|
+
if (!reminder) {
|
|
94
|
+
throw new Error(`Reminder with id ${id} not found`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (reminder.notificationId) {
|
|
98
|
+
try {
|
|
99
|
+
await scheduler.cancelNotification(reminder.notificationId);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('[useReminderActions] Failed to cancel notification:', error);
|
|
102
|
+
// Continue with deletion even if cancellation fails
|
|
103
|
+
}
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
await deleteReminder(id);
|
|
82
107
|
}, [deleteReminder]);
|
|
83
108
|
|
|
84
109
|
const toggleReminderEnabled = useCallback(async (id: string): Promise<void> => {
|
|
85
|
-
|
|
86
|
-
const reminder = reminders.find(r => r.id === id);
|
|
87
|
-
|
|
110
|
+
// Get current state BEFORE async operations to prevent race condition
|
|
111
|
+
const reminder = useRemindersStore.getState().reminders.find(r => r.id === id);
|
|
112
|
+
|
|
113
|
+
if (!reminder) {
|
|
114
|
+
throw new Error(`Reminder with id ${id} not found`);
|
|
115
|
+
}
|
|
88
116
|
|
|
89
117
|
if (reminder.enabled && reminder.notificationId) {
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
try {
|
|
119
|
+
await scheduler.cancelNotification(reminder.notificationId);
|
|
120
|
+
await updateReminder(id, { enabled: false, notificationId: undefined });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('[useReminderActions] Failed to disable reminder:', error);
|
|
123
|
+
throw error; // Re-throw to allow caller to handle
|
|
124
|
+
}
|
|
92
125
|
} else if (!reminder.enabled) {
|
|
93
126
|
try {
|
|
94
127
|
const trigger = buildTrigger(reminder);
|
|
@@ -99,8 +132,10 @@ export const useReminderActions = () => {
|
|
|
99
132
|
data: { reminderId: reminder.id },
|
|
100
133
|
});
|
|
101
134
|
await updateReminder(id, { enabled: true, notificationId });
|
|
102
|
-
} catch {
|
|
103
|
-
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('[useReminderActions] Failed to enable reminder:', error);
|
|
137
|
+
await updateReminder(id, { enabled: false }); // Ensure disabled state
|
|
138
|
+
throw error; // Re-throw to allow caller to handle
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
141
|
}, [updateReminder]);
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Displays list of reminders with add, edit, delete functionality
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React, {
|
|
6
|
+
import React, { useMemo, useCallback } from 'react';
|
|
7
7
|
import { View, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
|
|
8
8
|
import { AtomicText, AtomicIcon, AtomicSpinner } from '@umituz/react-native-design-system';
|
|
9
9
|
import { useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
10
10
|
import { ReminderItem } from '../components/ReminderItem';
|
|
11
|
-
import {
|
|
11
|
+
import { useReminders, useRemindersLoading } from '../../infrastructure/storage/RemindersStore';
|
|
12
12
|
import { useReminderActions } from '../../infrastructure/hooks/useReminderActions';
|
|
13
13
|
import type { Reminder, ReminderTranslations } from '../../../infrastructure/services/types';
|
|
14
14
|
|
|
@@ -61,7 +61,7 @@ export const StarRating: React.FC<StarRatingProps> = ({
|
|
|
61
61
|
);
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const getStyles = (
|
|
64
|
+
const getStyles = (_tokens: ReturnType<typeof useAppDesignTokens>) =>
|
|
65
65
|
StyleSheet.create({
|
|
66
66
|
container: {
|
|
67
67
|
flexDirection: "row",
|
|
@@ -22,33 +22,29 @@ interface State {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export class SettingsErrorBoundary extends Component<Props, State> {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
25
|
+
override state: State = {
|
|
26
|
+
hasError: false,
|
|
27
|
+
error: undefined,
|
|
28
|
+
};
|
|
30
29
|
|
|
31
30
|
static getDerivedStateFromError(error: Error): State {
|
|
32
31
|
return { hasError: true, error };
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
componentDidCatch(
|
|
34
|
+
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
36
35
|
// Log error to console in development
|
|
37
36
|
if (__DEV__) {
|
|
38
|
-
console.error('Settings Error Boundary caught an error:',
|
|
39
|
-
console.error('Error Info:',
|
|
37
|
+
console.error('Settings Error Boundary caught an error:', error);
|
|
38
|
+
console.error('Error Info:', errorInfo);
|
|
40
39
|
}
|
|
40
|
+
|
|
41
|
+
// TODO: Send to error tracking service in production
|
|
42
|
+
// Example: Sentry.captureException(error, { contexts: { react: { errorInfo } } });
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
render(): ReactNode {
|
|
44
|
-
|
|
45
|
-
const
|
|
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;
|
|
45
|
+
override render(): ReactNode {
|
|
46
|
+
const { hasError, error } = this.state;
|
|
47
|
+
const { children, fallback, fallbackTitle, fallbackMessage } = this.props;
|
|
52
48
|
|
|
53
49
|
if (hasError) {
|
|
54
50
|
if (fallback) {
|
|
@@ -140,4 +136,4 @@ const styles = StyleSheet.create({
|
|
|
140
136
|
textAlign: 'center',
|
|
141
137
|
lineHeight: 20,
|
|
142
138
|
},
|
|
143
|
-
});
|
|
139
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
|
-
import {
|
|
3
|
+
import { AtomicText } from "@umituz/react-native-design-system";
|
|
4
4
|
|
|
5
5
|
export interface SettingsFooterProps {
|
|
6
6
|
versionText?: string;
|
|
@@ -13,8 +13,6 @@ export const SettingsFooter: React.FC<SettingsFooterProps> = ({
|
|
|
13
13
|
appVersion,
|
|
14
14
|
versionLabel,
|
|
15
15
|
}) => {
|
|
16
|
-
const tokens = useAppDesignTokens();
|
|
17
|
-
|
|
18
16
|
const displayText = versionText || (appVersion && versionLabel
|
|
19
17
|
? `${versionLabel} ${appVersion}`
|
|
20
18
|
: appVersion);
|
|
@@ -51,6 +51,23 @@ export const SettingsItemCard: React.FC<SettingsItemCardProps> = ({
|
|
|
51
51
|
const tokens = useAppDesignTokens();
|
|
52
52
|
const colors = tokens.colors;
|
|
53
53
|
|
|
54
|
+
// Validate props in development
|
|
55
|
+
if (__DEV__) {
|
|
56
|
+
if (!title || typeof title !== 'string') {
|
|
57
|
+
console.warn('[SettingsItemCard] Invalid title prop:', title);
|
|
58
|
+
}
|
|
59
|
+
if (description && typeof description !== 'string') {
|
|
60
|
+
console.warn('[SettingsItemCard] Invalid description prop:', description);
|
|
61
|
+
}
|
|
62
|
+
if (showSwitch && !onSwitchChange) {
|
|
63
|
+
console.warn('[SettingsItemCard] Switch shown but no onSwitchChange provided. Switch changes will be ignored.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Sanitize string props (trim and limit length)
|
|
68
|
+
const sanitizedTitle = title?.trim().slice(0, 200) || '';
|
|
69
|
+
const sanitizedDescription = description?.trim().slice(0, 500);
|
|
70
|
+
|
|
54
71
|
const defaultIconBg = iconBgColor || withAlpha(colors.primary, 0.15);
|
|
55
72
|
const defaultIconColor = iconColor || colors.primary;
|
|
56
73
|
const isClickable = !!onPress && !showSwitch;
|
|
@@ -61,7 +78,11 @@ export const SettingsItemCard: React.FC<SettingsItemCardProps> = ({
|
|
|
61
78
|
return (
|
|
62
79
|
<AtomicSwitch
|
|
63
80
|
value={!!switchValue}
|
|
64
|
-
onValueChange={onSwitchChange || (() => {
|
|
81
|
+
onValueChange={onSwitchChange || (() => {
|
|
82
|
+
if (__DEV__) {
|
|
83
|
+
console.warn('[SettingsItemCard] Switch toggled but no onSwitchChange handler provided');
|
|
84
|
+
}
|
|
85
|
+
})}
|
|
65
86
|
disabled={disabled}
|
|
66
87
|
/>
|
|
67
88
|
);
|
|
@@ -82,13 +103,13 @@ export const SettingsItemCard: React.FC<SettingsItemCardProps> = ({
|
|
|
82
103
|
type="bodyLarge"
|
|
83
104
|
color={disabled ? "onSurfaceVariant" : "onSurface"}
|
|
84
105
|
numberOfLines={1}
|
|
85
|
-
style={{ marginBottom:
|
|
106
|
+
style={{ marginBottom: sanitizedDescription ? tokens.spacing.xs : 0, opacity: disabled ? 0.6 : 1 }}
|
|
86
107
|
>
|
|
87
|
-
{
|
|
108
|
+
{sanitizedTitle}
|
|
88
109
|
</AtomicText>
|
|
89
|
-
{!!
|
|
110
|
+
{!!sanitizedDescription && (
|
|
90
111
|
<AtomicText type="bodyMedium" color="textSecondary" numberOfLines={2}>
|
|
91
|
-
{
|
|
112
|
+
{sanitizedDescription}
|
|
92
113
|
</AtomicText>
|
|
93
114
|
)}
|
|
94
115
|
</View>
|
|
@@ -113,7 +134,7 @@ export const SettingsItemCard: React.FC<SettingsItemCardProps> = ({
|
|
|
113
134
|
{!!sectionTitle && (
|
|
114
135
|
<View style={styles.headerContainer}>
|
|
115
136
|
<AtomicText type="labelMedium" color="textSecondary" style={{ textTransform: 'uppercase' }}>
|
|
116
|
-
{sectionTitle}
|
|
137
|
+
{sectionTitle.trim().slice(0, 100)}
|
|
117
138
|
</AtomicText>
|
|
118
139
|
</View>
|
|
119
140
|
)}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { useQuery } from '@umituz/react-native-design-system';
|
|
8
8
|
import { getSettingsService } from '../../../infrastructure/services/SettingsService';
|
|
9
|
-
import type { UserSettings } from '../../../application/ports/ISettingsRepository';
|
|
10
9
|
|
|
11
10
|
export const SETTINGS_QUERY_KEY = ['settings'];
|
|
12
11
|
|
|
@@ -10,6 +10,14 @@ import { useUpdateSettingsMutation, useResetSettingsMutation } from './mutations
|
|
|
10
10
|
import type { UserSettings } from '../../application/ports/ISettingsRepository';
|
|
11
11
|
|
|
12
12
|
export const useSettings = (userId: string) => {
|
|
13
|
+
// Validate userId
|
|
14
|
+
if (!userId || typeof userId !== 'string' || userId.trim() === '') {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'Invalid userId: must be a non-empty string. ' +
|
|
17
|
+
'Received: ' + (userId === null ? 'null' : typeof userId)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
const {
|
|
14
22
|
data: settings,
|
|
15
23
|
isLoading: loading,
|
|
@@ -137,9 +137,9 @@ export const useSettingsScreenConfig = (
|
|
|
137
137
|
|
|
138
138
|
return {
|
|
139
139
|
displayName: userProfileData?.displayName || anonymousName,
|
|
140
|
-
userId: userProfileData?.userId ??
|
|
140
|
+
userId: userProfileData?.userId ?? undefined,
|
|
141
141
|
isAnonymous,
|
|
142
|
-
avatarUrl: userProfileData?.avatarUrl ??
|
|
142
|
+
avatarUrl: userProfileData?.avatarUrl ?? undefined,
|
|
143
143
|
onPress: isAnonymous ? handleSignIn : undefined,
|
|
144
144
|
accountSettingsRoute: isAnonymous ? undefined : "Account",
|
|
145
145
|
};
|
|
@@ -155,7 +155,7 @@ export const useSettingsScreenConfig = (
|
|
|
155
155
|
return {
|
|
156
156
|
profile: {
|
|
157
157
|
displayName: userProfileData?.displayName || user?.displayName || getTranslation("settings.profile.anonymousName", "Anonymous"),
|
|
158
|
-
userId: userProfileData?.userId ?? user?.uid ??
|
|
158
|
+
userId: userProfileData?.userId ?? user?.uid ?? undefined,
|
|
159
159
|
isAnonymous,
|
|
160
160
|
avatarUrl: userProfileData?.avatarUrl ?? user?.photoURL ?? undefined,
|
|
161
161
|
benefits: isAnonymous ? [
|
|
@@ -80,10 +80,13 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|
|
80
80
|
[config]
|
|
81
81
|
);
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// Feature detection hook (must be called at top level)
|
|
84
|
+
const detectedFeatures = useFeatureDetection(normalizedConfig, navigation, featureOptions);
|
|
85
|
+
|
|
86
|
+
// Memoize features to prevent unnecessary recalculations
|
|
84
87
|
const features = React.useMemo(
|
|
85
|
-
() =>
|
|
86
|
-
[
|
|
88
|
+
() => detectedFeatures,
|
|
89
|
+
[detectedFeatures]
|
|
87
90
|
);
|
|
88
91
|
|
|
89
92
|
// Determine if user profile should be shown (explicit prop takes priority, then config)
|
|
@@ -70,6 +70,7 @@ export const SettingsContent: React.FC<SettingsContentProps> = ({
|
|
|
70
70
|
}) => {
|
|
71
71
|
const { t } = useLocalization();
|
|
72
72
|
|
|
73
|
+
// Optimize: Only track individual feature flags instead of entire object
|
|
73
74
|
const hasAnyFeatures = useMemo(() =>
|
|
74
75
|
features.appearance ||
|
|
75
76
|
features.language ||
|
|
@@ -84,7 +85,21 @@ export const SettingsContent: React.FC<SettingsContentProps> = ({
|
|
|
84
85
|
features.wallet ||
|
|
85
86
|
features.gamification ||
|
|
86
87
|
customSections.length > 0,
|
|
87
|
-
[
|
|
88
|
+
[
|
|
89
|
+
features.appearance,
|
|
90
|
+
features.language,
|
|
91
|
+
features.notifications,
|
|
92
|
+
features.about,
|
|
93
|
+
features.legal,
|
|
94
|
+
features.disclaimer,
|
|
95
|
+
features.feedback,
|
|
96
|
+
features.rating,
|
|
97
|
+
features.faqs,
|
|
98
|
+
features.subscription,
|
|
99
|
+
features.wallet,
|
|
100
|
+
features.gamification,
|
|
101
|
+
customSections.length,
|
|
102
|
+
]
|
|
88
103
|
);
|
|
89
104
|
|
|
90
105
|
return (
|