@umituz/react-native-design-system 4.23.113 → 4.23.114
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/atoms/AtomicTouchable.tsx +22 -0
- package/src/atoms/badge/AtomicBadge.tsx +26 -28
- package/src/atoms/chip/AtomicChip.tsx +5 -5
- package/src/atoms/datepicker/components/DatePickerModal.tsx +4 -3
- package/src/atoms/input/hooks/useInputState.ts +1 -1
- package/src/atoms/picker/components/PickerModal.tsx +1 -1
- package/src/atoms/picker/hooks/usePickerState.ts +28 -15
- package/src/atoms/skeleton/AtomicSkeleton.tsx +5 -5
- package/src/device/infrastructure/services/DeviceCapabilityService.ts +1 -12
- package/src/filesystem/infrastructure/services/directory.service.ts +2 -2
- package/src/filesystem/infrastructure/services/download.service.ts +4 -4
- package/src/filesystem/infrastructure/services/file-manager.service.ts +4 -4
- package/src/filesystem/infrastructure/services/file-writer.service.ts +1 -1
- package/src/media/presentation/hooks/useMedia.ts +60 -22
- package/src/molecules/BaseModal.tsx +1 -0
- package/src/molecules/ConfirmationModalMain.tsx +1 -0
- package/src/molecules/ListItem.tsx +15 -1
- package/src/molecules/avatar/Avatar.tsx +28 -11
- package/src/molecules/bottom-sheet/components/BottomSheet.tsx +1 -0
- package/src/molecules/calendar/presentation/components/AtomicCalendar.tsx +1 -1
- package/src/responsive/useResponsive.ts +1 -1
- package/src/storage/presentation/hooks/usePersistentCache.ts +20 -12
- package/src/storage/presentation/hooks/useStore.ts +1 -0
- package/src/tanstack/presentation/hooks/usePrefetch.ts +14 -0
- package/src/theme/infrastructure/stores/themeStore.ts +13 -11
- package/src/timezone/infrastructure/services/BusinessCalendarManager.ts +1 -0
- package/src/timezone/infrastructure/services/CalendarManager.ts +2 -2
- package/src/timezone/infrastructure/services/DateComparisonUtils.ts +1 -0
- package/src/timezone/infrastructure/services/DateFormatter.ts +3 -2
- package/src/timezone/infrastructure/services/DateRangeUtils.ts +1 -0
- package/src/timezone/infrastructure/utils/TimezoneParsers.ts +27 -0
- package/src/utilities/sharing/presentation/hooks/useSharing.ts +44 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "4.23.
|
|
3
|
+
"version": "4.23.114",
|
|
4
4
|
"description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image, timezone, offline, onboarding, and loading utilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -16,6 +16,18 @@ export interface AtomicTouchableProps {
|
|
|
16
16
|
activeOpacity?: number;
|
|
17
17
|
style?: StyleProp<ViewStyle>;
|
|
18
18
|
testID?: string;
|
|
19
|
+
// Accessibility props
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
accessibilityHint?: string;
|
|
22
|
+
accessibilityRole?: 'button' | 'link' | 'none' | 'text' | 'search' | 'image' | 'adjustable' | 'imagebutton' | 'header' | 'summary' | 'alert' | 'checkbox' | 'combobox' | 'menu' | 'menubar' | 'menuitem' | 'progressbar' | 'radio' | 'radiogroup' | 'scrollbar' | 'spinbutton' | 'switch' | 'tab' | 'tablist' | 'timer' | 'toolbar';
|
|
23
|
+
accessibilityState?: {
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
selected?: boolean;
|
|
26
|
+
checked?: boolean | 'mixed';
|
|
27
|
+
busy?: boolean;
|
|
28
|
+
expanded?: boolean;
|
|
29
|
+
};
|
|
30
|
+
accessible?: boolean;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export const AtomicTouchable: React.FC<AtomicTouchableProps> = ({
|
|
@@ -26,6 +38,11 @@ export const AtomicTouchable: React.FC<AtomicTouchableProps> = ({
|
|
|
26
38
|
activeOpacity = 0.7,
|
|
27
39
|
style,
|
|
28
40
|
testID,
|
|
41
|
+
accessibilityLabel,
|
|
42
|
+
accessibilityHint,
|
|
43
|
+
accessibilityRole = 'button',
|
|
44
|
+
accessibilityState,
|
|
45
|
+
accessible = true,
|
|
29
46
|
}) => {
|
|
30
47
|
return (
|
|
31
48
|
<TouchableOpacity
|
|
@@ -35,6 +52,11 @@ export const AtomicTouchable: React.FC<AtomicTouchableProps> = ({
|
|
|
35
52
|
activeOpacity={activeOpacity}
|
|
36
53
|
style={style}
|
|
37
54
|
testID={testID}
|
|
55
|
+
accessibilityLabel={accessibilityLabel}
|
|
56
|
+
accessibilityHint={accessibilityHint}
|
|
57
|
+
accessibilityRole={accessibilityRole}
|
|
58
|
+
accessibilityState={{ ...accessibilityState, disabled }}
|
|
59
|
+
accessible={accessible}
|
|
38
60
|
>
|
|
39
61
|
{children}
|
|
40
62
|
</TouchableOpacity>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Reusable badge for labels, status indicators, and tags
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React from "react";
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
7
|
import { View, StyleSheet, type StyleProp, type ViewStyle, type TextStyle } from "react-native";
|
|
8
8
|
import { AtomicText } from "../AtomicText";
|
|
9
9
|
import { AtomicIcon, type IconName } from "../icon";
|
|
@@ -43,34 +43,34 @@ export const AtomicBadge: React.FC<AtomicBadgeProps> = React.memo(({
|
|
|
43
43
|
}) => {
|
|
44
44
|
const tokens = useAppDesignTokens();
|
|
45
45
|
|
|
46
|
-
const sizeConfig = {
|
|
47
|
-
sm: {
|
|
48
|
-
paddingH: 6 * tokens.spacingMultiplier,
|
|
49
|
-
paddingV: 2 * tokens.spacingMultiplier,
|
|
50
|
-
fontSize: 10 * tokens.spacingMultiplier,
|
|
51
|
-
iconSize: 10 * tokens.spacingMultiplier,
|
|
52
|
-
gap: 3 * tokens.spacingMultiplier,
|
|
53
|
-
radius: 4 * tokens.spacingMultiplier
|
|
46
|
+
const sizeConfig = useMemo(() => ({
|
|
47
|
+
sm: {
|
|
48
|
+
paddingH: 6 * tokens.spacingMultiplier,
|
|
49
|
+
paddingV: 2 * tokens.spacingMultiplier,
|
|
50
|
+
fontSize: 10 * tokens.spacingMultiplier,
|
|
51
|
+
iconSize: 10 * tokens.spacingMultiplier,
|
|
52
|
+
gap: 3 * tokens.spacingMultiplier,
|
|
53
|
+
radius: 4 * tokens.spacingMultiplier
|
|
54
54
|
},
|
|
55
|
-
md: {
|
|
56
|
-
paddingH: 8 * tokens.spacingMultiplier,
|
|
57
|
-
paddingV: 4 * tokens.spacingMultiplier,
|
|
58
|
-
fontSize: 11 * tokens.spacingMultiplier,
|
|
59
|
-
iconSize: 12 * tokens.spacingMultiplier,
|
|
60
|
-
gap: 4 * tokens.spacingMultiplier,
|
|
61
|
-
radius: 6 * tokens.spacingMultiplier
|
|
55
|
+
md: {
|
|
56
|
+
paddingH: 8 * tokens.spacingMultiplier,
|
|
57
|
+
paddingV: 4 * tokens.spacingMultiplier,
|
|
58
|
+
fontSize: 11 * tokens.spacingMultiplier,
|
|
59
|
+
iconSize: 12 * tokens.spacingMultiplier,
|
|
60
|
+
gap: 4 * tokens.spacingMultiplier,
|
|
61
|
+
radius: 6 * tokens.spacingMultiplier
|
|
62
62
|
},
|
|
63
|
-
lg: {
|
|
64
|
-
paddingH: 12 * tokens.spacingMultiplier,
|
|
65
|
-
paddingV: 6 * tokens.spacingMultiplier,
|
|
66
|
-
fontSize: 13 * tokens.spacingMultiplier,
|
|
67
|
-
iconSize: 14 * tokens.spacingMultiplier,
|
|
68
|
-
gap: 5 * tokens.spacingMultiplier,
|
|
69
|
-
radius: 8 * tokens.spacingMultiplier
|
|
63
|
+
lg: {
|
|
64
|
+
paddingH: 12 * tokens.spacingMultiplier,
|
|
65
|
+
paddingV: 6 * tokens.spacingMultiplier,
|
|
66
|
+
fontSize: 13 * tokens.spacingMultiplier,
|
|
67
|
+
iconSize: 14 * tokens.spacingMultiplier,
|
|
68
|
+
gap: 5 * tokens.spacingMultiplier,
|
|
69
|
+
radius: 8 * tokens.spacingMultiplier
|
|
70
70
|
},
|
|
71
|
-
}[size];
|
|
71
|
+
}[size]), [size, tokens.spacingMultiplier]);
|
|
72
72
|
|
|
73
|
-
const
|
|
73
|
+
const colors = useMemo(() => {
|
|
74
74
|
switch (variant) {
|
|
75
75
|
case "primary":
|
|
76
76
|
return { bg: tokens.colors.primaryLight, text: tokens.colors.primary };
|
|
@@ -87,9 +87,7 @@ export const AtomicBadge: React.FC<AtomicBadgeProps> = React.memo(({
|
|
|
87
87
|
default:
|
|
88
88
|
return { bg: tokens.colors.primaryLight, text: tokens.colors.primary };
|
|
89
89
|
}
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const colors = getVariantColors();
|
|
90
|
+
}, [variant, tokens.colors]);
|
|
93
91
|
|
|
94
92
|
const containerStyle: StyleProp<ViewStyle> = [
|
|
95
93
|
styles.container,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Refactored: Extracted configs, styles, and types
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React from 'react';
|
|
6
|
+
import React, { useMemo } from 'react';
|
|
7
7
|
import { View, ViewStyle, TouchableOpacity } from 'react-native';
|
|
8
8
|
import { AtomicText } from '../AtomicText';
|
|
9
9
|
import { AtomicIcon } from '../icon';
|
|
@@ -33,10 +33,10 @@ export const AtomicChip: React.FC<AtomicChipProps> = React.memo(({
|
|
|
33
33
|
}) => {
|
|
34
34
|
const tokens = useAppDesignTokens();
|
|
35
35
|
|
|
36
|
-
const sizeConfig = getChipSizeConfig(size, tokens);
|
|
37
|
-
const colorConfig = getChipColorConfig(color, variant, tokens);
|
|
38
|
-
const borderStyle = getChipBorderStyle(variant, tokens);
|
|
39
|
-
const selectedStyle = getChipSelectedStyle(selected, tokens);
|
|
36
|
+
const sizeConfig = useMemo(() => getChipSizeConfig(size, tokens), [size, tokens]);
|
|
37
|
+
const colorConfig = useMemo(() => getChipColorConfig(color, variant, tokens), [color, variant, tokens]);
|
|
38
|
+
const borderStyle = useMemo(() => getChipBorderStyle(variant, tokens), [variant, tokens]);
|
|
39
|
+
const selectedStyle = useMemo(() => getChipSelectedStyle(selected, tokens), [selected, tokens]);
|
|
40
40
|
|
|
41
41
|
// Apply custom colors if provided
|
|
42
42
|
const finalBackgroundColor = backgroundColor || colorConfig.bg;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Extracted from AtomicDatePicker for better separation of concerns.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React from 'react';
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
9
9
|
import {
|
|
10
10
|
View,
|
|
11
11
|
Modal,
|
|
@@ -52,7 +52,7 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
52
52
|
const tokens = useAppDesignTokens();
|
|
53
53
|
const insets = useSafeAreaInsets();
|
|
54
54
|
|
|
55
|
-
const modalStyles = StyleSheet.create({
|
|
55
|
+
const modalStyles = useMemo(() => StyleSheet.create({
|
|
56
56
|
overlay: {
|
|
57
57
|
flex: 1,
|
|
58
58
|
backgroundColor: `rgba(0, 0, 0, ${overlayOpacity})`,
|
|
@@ -89,7 +89,7 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
89
89
|
fontWeight: '500',
|
|
90
90
|
color: tokens.colors.onPrimary,
|
|
91
91
|
},
|
|
92
|
-
});
|
|
92
|
+
}), [overlayOpacity, tokens, insets.bottom]);
|
|
93
93
|
|
|
94
94
|
if (Platform.OS !== 'ios') {
|
|
95
95
|
return null;
|
|
@@ -102,6 +102,7 @@ export const DatePickerModal: React.FC<DatePickerModalProps> = ({
|
|
|
102
102
|
animationType="none"
|
|
103
103
|
onRequestClose={onClose}
|
|
104
104
|
testID={`${testID}-modal`}
|
|
105
|
+
accessibilityViewIsModal={true}
|
|
105
106
|
>
|
|
106
107
|
<View style={modalStyles.overlay}>
|
|
107
108
|
<View style={modalStyles.container}>
|
|
@@ -54,5 +54,5 @@ export const useInputState = ({
|
|
|
54
54
|
setIsFocused,
|
|
55
55
|
handleTextChange,
|
|
56
56
|
togglePasswordVisibility,
|
|
57
|
-
}), [localValue, isFocused, isPasswordVisible, characterCount, isAtMaxLength, handleTextChange, togglePasswordVisibility]);
|
|
57
|
+
}), [localValue, isFocused, isPasswordVisible, characterCount, isAtMaxLength, setIsFocused, handleTextChange, togglePasswordVisibility]);
|
|
58
58
|
};
|
|
@@ -95,7 +95,7 @@ export const PickerModal: React.FC<PickerModalProps> = React.memo(({
|
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
return (
|
|
98
|
-
<Modal visible={visible} animationType="none" transparent onRequestClose={onClose} testID={`${testID}-modal`}>
|
|
98
|
+
<Modal visible={visible} animationType="none" transparent onRequestClose={onClose} testID={`${testID}-modal`} accessibilityViewIsModal={true}>
|
|
99
99
|
<View style={styles.overlay}>
|
|
100
100
|
<View style={[styles.container, { paddingBottom: insets.bottom + tokens.spacing.md }]}>
|
|
101
101
|
<View style={styles.header}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo } from 'react';
|
|
1
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
2
2
|
import type { PickerOption } from '../../picker/types';
|
|
3
3
|
|
|
4
4
|
interface UsePickerStateProps {
|
|
@@ -71,23 +71,23 @@ export const usePickerState = ({
|
|
|
71
71
|
/**
|
|
72
72
|
* Handle modal open
|
|
73
73
|
*/
|
|
74
|
-
const openModal = () => {
|
|
74
|
+
const openModal = useCallback(() => {
|
|
75
75
|
setModalVisible(true);
|
|
76
76
|
setSearchQuery('');
|
|
77
|
-
};
|
|
77
|
+
}, []);
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Handle modal close
|
|
81
81
|
*/
|
|
82
|
-
const closeModal = () => {
|
|
82
|
+
const closeModal = useCallback(() => {
|
|
83
83
|
setModalVisible(false);
|
|
84
84
|
setSearchQuery('');
|
|
85
|
-
};
|
|
85
|
+
}, []);
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Handle option selection
|
|
89
89
|
*/
|
|
90
|
-
const handleSelect = (optionValue: string) => {
|
|
90
|
+
const handleSelect = useCallback((optionValue: string) => {
|
|
91
91
|
if (multiple) {
|
|
92
92
|
const newValues = selectedValues.includes(optionValue)
|
|
93
93
|
? selectedValues.filter((v) => v !== optionValue)
|
|
@@ -99,30 +99,30 @@ export const usePickerState = ({
|
|
|
99
99
|
closeModal();
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
};
|
|
102
|
+
}, [multiple, selectedValues, onChange, autoClose, closeModal]);
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* Handle clear selection
|
|
106
106
|
*/
|
|
107
|
-
const handleClear = () => {
|
|
107
|
+
const handleClear = useCallback(() => {
|
|
108
108
|
onChange(multiple ? [] : '');
|
|
109
|
-
};
|
|
109
|
+
}, [onChange, multiple]);
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Handle search query change
|
|
113
113
|
*/
|
|
114
|
-
const handleSearch = (query: string) => {
|
|
114
|
+
const handleSearch = useCallback((query: string) => {
|
|
115
115
|
setSearchQuery(query);
|
|
116
|
-
};
|
|
116
|
+
}, []);
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Handle chip removal
|
|
120
120
|
*/
|
|
121
|
-
const handleChipRemove = (value: string) => {
|
|
121
|
+
const handleChipRemove = useCallback((value: string) => {
|
|
122
122
|
handleSelect(value);
|
|
123
|
-
};
|
|
123
|
+
}, [handleSelect]);
|
|
124
124
|
|
|
125
|
-
return {
|
|
125
|
+
return useMemo(() => ({
|
|
126
126
|
modalVisible,
|
|
127
127
|
searchQuery,
|
|
128
128
|
selectedValues,
|
|
@@ -135,5 +135,18 @@ export const usePickerState = ({
|
|
|
135
135
|
handleClear,
|
|
136
136
|
handleSearch,
|
|
137
137
|
handleChipRemove,
|
|
138
|
-
}
|
|
138
|
+
}), [
|
|
139
|
+
modalVisible,
|
|
140
|
+
searchQuery,
|
|
141
|
+
selectedValues,
|
|
142
|
+
selectedOptions,
|
|
143
|
+
filteredOptions,
|
|
144
|
+
displayText,
|
|
145
|
+
openModal,
|
|
146
|
+
closeModal,
|
|
147
|
+
handleSelect,
|
|
148
|
+
handleClear,
|
|
149
|
+
handleSearch,
|
|
150
|
+
handleChipRemove,
|
|
151
|
+
]);
|
|
139
152
|
};
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import React from 'react';
|
|
25
|
+
import React, { useMemo } from 'react';
|
|
26
26
|
import { View, StyleSheet, type StyleProp, type ViewStyle, type DimensionValue } from 'react-native';
|
|
27
27
|
import { useAppDesignTokens } from '../../theme';
|
|
28
28
|
import type { SkeletonPattern, SkeletonConfig } from './AtomicSkeleton.types';
|
|
@@ -50,8 +50,8 @@ const SkeletonItem: React.FC<{
|
|
|
50
50
|
config: SkeletonConfig;
|
|
51
51
|
baseColor: string;
|
|
52
52
|
multiplier: number;
|
|
53
|
-
}> = ({ config, baseColor, multiplier }) => {
|
|
54
|
-
const itemStyles = StyleSheet.create({
|
|
53
|
+
}> = React.memo(({ config, baseColor, multiplier }) => {
|
|
54
|
+
const itemStyles = useMemo(() => StyleSheet.create({
|
|
55
55
|
item: {
|
|
56
56
|
...styles.skeleton,
|
|
57
57
|
width: (typeof config.width === 'number' ? config.width * multiplier : config.width) as DimensionValue,
|
|
@@ -60,10 +60,10 @@ const SkeletonItem: React.FC<{
|
|
|
60
60
|
marginBottom: config.marginBottom ? config.marginBottom * multiplier : undefined,
|
|
61
61
|
backgroundColor: baseColor,
|
|
62
62
|
},
|
|
63
|
-
});
|
|
63
|
+
}), [config, baseColor, multiplier]);
|
|
64
64
|
|
|
65
65
|
return <View style={itemStyles.item} />;
|
|
66
|
-
};
|
|
66
|
+
});
|
|
67
67
|
|
|
68
68
|
export const AtomicSkeleton: React.FC<AtomicSkeletonProps> = ({
|
|
69
69
|
pattern = 'list',
|
|
@@ -54,19 +54,8 @@ export class DeviceCapabilityService {
|
|
|
54
54
|
*/
|
|
55
55
|
static async hasNotch(): Promise<boolean> {
|
|
56
56
|
try {
|
|
57
|
-
if (Platform.OS !== 'ios') {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
57
|
const info = await DeviceInfoService.getDeviceInfo();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// iPhone X and newer (with notch or dynamic island)
|
|
65
|
-
return (
|
|
66
|
-
modelName.includes('iphone x') ||
|
|
67
|
-
modelName.includes('iphone 1') || // 11, 12, 13, 14, 15
|
|
68
|
-
modelName.includes('pro')
|
|
69
|
-
);
|
|
58
|
+
return this.hasNotchFromInfo(info);
|
|
70
59
|
} catch {
|
|
71
60
|
return false;
|
|
72
61
|
}
|
|
@@ -12,7 +12,7 @@ import type { DirectoryType } from "../../domain/entities/File";
|
|
|
12
12
|
export async function createDirectory(uri: string): Promise<boolean> {
|
|
13
13
|
try {
|
|
14
14
|
const dir = new Directory(uri);
|
|
15
|
-
dir.create({ intermediates: true, idempotent: true });
|
|
15
|
+
await dir.create({ intermediates: true, idempotent: true });
|
|
16
16
|
return true;
|
|
17
17
|
} catch {
|
|
18
18
|
return false;
|
|
@@ -25,7 +25,7 @@ export async function createDirectory(uri: string): Promise<boolean> {
|
|
|
25
25
|
export async function listDirectory(uri: string): Promise<string[]> {
|
|
26
26
|
try {
|
|
27
27
|
const dir = new Directory(uri);
|
|
28
|
-
const items = dir.list();
|
|
28
|
+
const items = await dir.list();
|
|
29
29
|
return items.map((item) => item.uri);
|
|
30
30
|
} catch {
|
|
31
31
|
return [];
|
|
@@ -38,7 +38,7 @@ export async function downloadFileWithProgress(
|
|
|
38
38
|
): Promise<DownloadWithProgressResult> {
|
|
39
39
|
try {
|
|
40
40
|
const dir = new Directory(cacheDir);
|
|
41
|
-
if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
|
|
41
|
+
if (!dir.exists) await dir.create({ intermediates: true, idempotent: true });
|
|
42
42
|
|
|
43
43
|
const destUri = getCacheUri(url, cacheDir);
|
|
44
44
|
if (new File(destUri).exists) return { success: true, uri: destUri, fromCache: true };
|
|
@@ -70,7 +70,7 @@ export async function downloadFileWithProgress(
|
|
|
70
70
|
const all = new Uint8Array(received);
|
|
71
71
|
let pos = 0;
|
|
72
72
|
for (const c of chunks) { all.set(c, pos); pos += c.length; }
|
|
73
|
-
new File(destUri).write(all);
|
|
73
|
+
await new File(destUri).write(all);
|
|
74
74
|
|
|
75
75
|
return { success: true, uri: destUri, fromCache: false };
|
|
76
76
|
} finally {
|
|
@@ -81,8 +81,8 @@ export async function downloadFileWithProgress(
|
|
|
81
81
|
|
|
82
82
|
export const isUrlCached = (url: string, dir: string) => new File(getCacheUri(url, dir)).exists;
|
|
83
83
|
export const getCachedFileUri = (url: string, dir: string) => isUrlCached(url, dir) ? getCacheUri(url, dir) : null;
|
|
84
|
-
export const deleteCachedFile = (url: string, dir: string) => {
|
|
84
|
+
export const deleteCachedFile = async (url: string, dir: string) => {
|
|
85
85
|
const f = new File(getCacheUri(url, dir));
|
|
86
|
-
if (f.exists) f.delete();
|
|
86
|
+
if (f.exists) await f.delete();
|
|
87
87
|
return true;
|
|
88
88
|
};
|
|
@@ -15,7 +15,7 @@ export async function deleteFile(uri: string): Promise<boolean> {
|
|
|
15
15
|
try {
|
|
16
16
|
const file = new File(uri);
|
|
17
17
|
if (file.exists) {
|
|
18
|
-
file.delete();
|
|
18
|
+
await file.delete();
|
|
19
19
|
return true;
|
|
20
20
|
}
|
|
21
21
|
} catch {
|
|
@@ -26,7 +26,7 @@ export async function deleteFile(uri: string): Promise<boolean> {
|
|
|
26
26
|
try {
|
|
27
27
|
const dir = new Directory(uri);
|
|
28
28
|
if (dir.exists) {
|
|
29
|
-
dir.delete();
|
|
29
|
+
await dir.delete();
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
} catch {
|
|
@@ -49,7 +49,7 @@ export async function copyFile(
|
|
|
49
49
|
try {
|
|
50
50
|
const sourceFile = new File(sourceUri);
|
|
51
51
|
const destination = new File(destinationUri);
|
|
52
|
-
sourceFile.copy(destination);
|
|
52
|
+
await sourceFile.copy(destination);
|
|
53
53
|
return { success: true, uri: destinationUri };
|
|
54
54
|
} catch (error) {
|
|
55
55
|
return {
|
|
@@ -69,7 +69,7 @@ export async function moveFile(
|
|
|
69
69
|
try {
|
|
70
70
|
const sourceFile = new File(sourceUri);
|
|
71
71
|
const destination = new File(destinationUri);
|
|
72
|
-
sourceFile.move(destination);
|
|
72
|
+
await sourceFile.move(destination);
|
|
73
73
|
return { success: true, uri: destinationUri };
|
|
74
74
|
} catch (error) {
|
|
75
75
|
return {
|
|
@@ -18,7 +18,7 @@ export async function writeFile(
|
|
|
18
18
|
try {
|
|
19
19
|
const encodingType = getEncodingType(encoding);
|
|
20
20
|
const file = new File(uri);
|
|
21
|
-
file.write(content, {
|
|
21
|
+
await file.write(content, {
|
|
22
22
|
encoding: encodingType as ExpoEncodingType,
|
|
23
23
|
});
|
|
24
24
|
return { success: true, uri };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Provides camera, gallery picking functionality.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useState, useCallback } from "react";
|
|
8
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
9
9
|
import { MediaPickerService } from "../../infrastructure/services/MediaPickerService";
|
|
10
10
|
import { PermissionManager } from "../../infrastructure/utils/PermissionManager";
|
|
11
11
|
import type {
|
|
@@ -39,24 +39,38 @@ export const useMedia = () => {
|
|
|
39
39
|
const [isLoading, setIsLoading] = useState(false);
|
|
40
40
|
const [error, setError] = useState<string | null>(null);
|
|
41
41
|
|
|
42
|
+
// Track mounted state to prevent setState on unmounted component
|
|
43
|
+
const isMountedRef = useRef(true);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
return () => {
|
|
46
|
+
isMountedRef.current = false;
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
42
50
|
const pickImage = useCallback(
|
|
43
51
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (isMountedRef.current) {
|
|
53
|
+
setIsLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
}
|
|
46
56
|
try {
|
|
47
57
|
const result = await MediaPickerService.pickSingleImage(options);
|
|
48
58
|
// Set error from validation result if present
|
|
49
|
-
if (result.errorMessage) {
|
|
59
|
+
if (result.errorMessage && isMountedRef.current) {
|
|
50
60
|
setError(result.errorMessage);
|
|
51
61
|
}
|
|
52
62
|
return result;
|
|
53
63
|
} catch (err) {
|
|
54
64
|
const errorMessage =
|
|
55
65
|
err instanceof Error ? err.message : "Failed to pick image";
|
|
56
|
-
|
|
66
|
+
if (isMountedRef.current) {
|
|
67
|
+
setError(errorMessage);
|
|
68
|
+
}
|
|
57
69
|
return { canceled: true };
|
|
58
70
|
} finally {
|
|
59
|
-
|
|
71
|
+
if (isMountedRef.current) {
|
|
72
|
+
setIsLoading(false);
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
},
|
|
62
76
|
[]
|
|
@@ -64,18 +78,24 @@ export const useMedia = () => {
|
|
|
64
78
|
|
|
65
79
|
const pickMultipleImages = useCallback(
|
|
66
80
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
if (isMountedRef.current) {
|
|
82
|
+
setIsLoading(true);
|
|
83
|
+
setError(null);
|
|
84
|
+
}
|
|
69
85
|
try {
|
|
70
86
|
const result = await MediaPickerService.pickMultipleImages(options);
|
|
71
87
|
return result;
|
|
72
88
|
} catch (err) {
|
|
73
89
|
const errorMessage =
|
|
74
90
|
err instanceof Error ? err.message : "Failed to pick images";
|
|
75
|
-
|
|
91
|
+
if (isMountedRef.current) {
|
|
92
|
+
setError(errorMessage);
|
|
93
|
+
}
|
|
76
94
|
return { canceled: true };
|
|
77
95
|
} finally {
|
|
78
|
-
|
|
96
|
+
if (isMountedRef.current) {
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
}
|
|
79
99
|
}
|
|
80
100
|
},
|
|
81
101
|
[]
|
|
@@ -83,18 +103,24 @@ export const useMedia = () => {
|
|
|
83
103
|
|
|
84
104
|
const pickVideo = useCallback(
|
|
85
105
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
if (isMountedRef.current) {
|
|
107
|
+
setIsLoading(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
}
|
|
88
110
|
try {
|
|
89
111
|
const result = await MediaPickerService.pickVideo(options);
|
|
90
112
|
return result;
|
|
91
113
|
} catch (err) {
|
|
92
114
|
const errorMessage =
|
|
93
115
|
err instanceof Error ? err.message : "Failed to pick video";
|
|
94
|
-
|
|
116
|
+
if (isMountedRef.current) {
|
|
117
|
+
setError(errorMessage);
|
|
118
|
+
}
|
|
95
119
|
return { canceled: true };
|
|
96
120
|
} finally {
|
|
97
|
-
|
|
121
|
+
if (isMountedRef.current) {
|
|
122
|
+
setIsLoading(false);
|
|
123
|
+
}
|
|
98
124
|
}
|
|
99
125
|
},
|
|
100
126
|
[]
|
|
@@ -102,18 +128,24 @@ export const useMedia = () => {
|
|
|
102
128
|
|
|
103
129
|
const launchCamera = useCallback(
|
|
104
130
|
async (options?: CameraOptions): Promise<MediaPickerResult> => {
|
|
105
|
-
|
|
106
|
-
|
|
131
|
+
if (isMountedRef.current) {
|
|
132
|
+
setIsLoading(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
}
|
|
107
135
|
try {
|
|
108
136
|
const result = await MediaPickerService.launchCamera(options);
|
|
109
137
|
return result;
|
|
110
138
|
} catch (err) {
|
|
111
139
|
const errorMessage =
|
|
112
140
|
err instanceof Error ? err.message : "Failed to launch camera";
|
|
113
|
-
|
|
141
|
+
if (isMountedRef.current) {
|
|
142
|
+
setError(errorMessage);
|
|
143
|
+
}
|
|
114
144
|
return { canceled: true };
|
|
115
145
|
} finally {
|
|
116
|
-
|
|
146
|
+
if (isMountedRef.current) {
|
|
147
|
+
setIsLoading(false);
|
|
148
|
+
}
|
|
117
149
|
}
|
|
118
150
|
},
|
|
119
151
|
[]
|
|
@@ -121,18 +153,24 @@ export const useMedia = () => {
|
|
|
121
153
|
|
|
122
154
|
const launchCameraForVideo = useCallback(
|
|
123
155
|
async (options?: CameraOptions): Promise<MediaPickerResult> => {
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
if (isMountedRef.current) {
|
|
157
|
+
setIsLoading(true);
|
|
158
|
+
setError(null);
|
|
159
|
+
}
|
|
126
160
|
try {
|
|
127
161
|
const result = await MediaPickerService.launchCameraForVideo(options);
|
|
128
162
|
return result;
|
|
129
163
|
} catch (err) {
|
|
130
164
|
const errorMessage =
|
|
131
165
|
err instanceof Error ? err.message : "Failed to record video";
|
|
132
|
-
|
|
166
|
+
if (isMountedRef.current) {
|
|
167
|
+
setError(errorMessage);
|
|
168
|
+
}
|
|
133
169
|
return { canceled: true };
|
|
134
170
|
} finally {
|
|
135
|
-
|
|
171
|
+
if (isMountedRef.current) {
|
|
172
|
+
setIsLoading(false);
|
|
173
|
+
}
|
|
136
174
|
}
|
|
137
175
|
},
|
|
138
176
|
[]
|
|
@@ -14,8 +14,22 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|
|
14
14
|
const listItemStyles = getListItemStyles(tokens);
|
|
15
15
|
const Component = onPress ? TouchableOpacity : View;
|
|
16
16
|
|
|
17
|
+
const accessibilityProps = onPress
|
|
18
|
+
? {
|
|
19
|
+
accessibilityRole: 'button' as const,
|
|
20
|
+
accessibilityLabel: title,
|
|
21
|
+
accessibilityState: { disabled },
|
|
22
|
+
}
|
|
23
|
+
: {};
|
|
24
|
+
|
|
17
25
|
return (
|
|
18
|
-
<Component
|
|
26
|
+
<Component
|
|
27
|
+
style={[listItemStyles.container, disabled ? listItemStyles.disabled : undefined, style]}
|
|
28
|
+
onPress={onPress}
|
|
29
|
+
disabled={disabled}
|
|
30
|
+
activeOpacity={0.7}
|
|
31
|
+
{...accessibilityProps}
|
|
32
|
+
>
|
|
19
33
|
{leftIcon && (
|
|
20
34
|
<AtomicIcon
|
|
21
35
|
name={leftIcon}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Handles loading states, fallbacks, and status indicators.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React from 'react';
|
|
9
|
-
import { View, Image, StyleSheet, type StyleProp, type ViewStyle, type ImageStyle } from 'react-native';
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
9
|
+
import { View, Image, StyleSheet, TouchableOpacity, type StyleProp, type ViewStyle, type ImageStyle } from 'react-native';
|
|
10
10
|
import { useAppDesignTokens } from '../../theme';
|
|
11
11
|
import { AtomicText, AtomicIcon } from '../../atoms';
|
|
12
12
|
import type { AvatarSize, AvatarShape } from './Avatar.types';
|
|
@@ -62,20 +62,29 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
62
62
|
onPress,
|
|
63
63
|
}) => {
|
|
64
64
|
const tokens = useAppDesignTokens();
|
|
65
|
-
const config = SIZE_CONFIGS[size];
|
|
65
|
+
const config = useMemo(() => SIZE_CONFIGS[size], [size]);
|
|
66
66
|
|
|
67
67
|
// Determine avatar type and content
|
|
68
68
|
const hasImage = !!uri;
|
|
69
69
|
const hasName = !!name;
|
|
70
|
-
const initials =
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const initials = useMemo(
|
|
71
|
+
() => (hasName ? AvatarUtils.generateInitials(name) : AVATAR_CONSTANTS.FALLBACK_INITIALS),
|
|
72
|
+
[hasName, name]
|
|
73
|
+
);
|
|
74
|
+
const bgColor = useMemo(
|
|
75
|
+
() => backgroundColor || (hasName ? AvatarUtils.getColorForName(name) : tokens.colors.surfaceSecondary),
|
|
76
|
+
[backgroundColor, hasName, name, tokens.colors.surfaceSecondary]
|
|
77
|
+
);
|
|
78
|
+
const borderRadius = useMemo(
|
|
79
|
+
() => AvatarUtils.getBorderRadius(shape, config.size),
|
|
80
|
+
[shape, config.size]
|
|
81
|
+
);
|
|
73
82
|
|
|
74
83
|
// Status indicator position
|
|
75
|
-
const statusPosition = {
|
|
84
|
+
const statusPosition = useMemo(() => ({
|
|
76
85
|
bottom: 0,
|
|
77
86
|
right: 0,
|
|
78
|
-
};
|
|
87
|
+
}), []);
|
|
79
88
|
|
|
80
89
|
const renderContent = () => {
|
|
81
90
|
if (hasImage) {
|
|
@@ -122,8 +131,10 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
122
131
|
);
|
|
123
132
|
};
|
|
124
133
|
|
|
134
|
+
const AvatarWrapper = onPress ? TouchableOpacity : View;
|
|
135
|
+
|
|
125
136
|
return (
|
|
126
|
-
<
|
|
137
|
+
<AvatarWrapper
|
|
127
138
|
style={[
|
|
128
139
|
styles.container,
|
|
129
140
|
{
|
|
@@ -134,7 +145,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
134
145
|
},
|
|
135
146
|
style,
|
|
136
147
|
]}
|
|
137
|
-
|
|
148
|
+
onPress={onPress}
|
|
149
|
+
disabled={!onPress}
|
|
150
|
+
accessibilityRole={onPress ? 'button' : 'image'}
|
|
151
|
+
accessibilityLabel={name || 'User avatar'}
|
|
152
|
+
accessible={true}
|
|
138
153
|
>
|
|
139
154
|
{renderContent()}
|
|
140
155
|
|
|
@@ -153,9 +168,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
153
168
|
...statusPosition,
|
|
154
169
|
},
|
|
155
170
|
]}
|
|
171
|
+
accessibilityLabel={`Status: ${status}`}
|
|
172
|
+
accessibilityRole="none"
|
|
156
173
|
/>
|
|
157
174
|
)}
|
|
158
|
-
</
|
|
175
|
+
</AvatarWrapper>
|
|
159
176
|
);
|
|
160
177
|
};
|
|
161
178
|
|
|
@@ -99,6 +99,7 @@ export const BottomSheet = forwardRef<BottomSheetRef, BottomSheetProps>((props,
|
|
|
99
99
|
animationType="none"
|
|
100
100
|
onRequestClose={dismiss}
|
|
101
101
|
statusBarTranslucent
|
|
102
|
+
accessibilityViewIsModal={true}
|
|
102
103
|
>
|
|
103
104
|
<Pressable style={styles.overlay} onPress={dismiss}>
|
|
104
105
|
<View style={styles.container}>
|
|
@@ -124,7 +124,7 @@ export const useResponsive = (): UseResponsiveReturn => {
|
|
|
124
124
|
getGridCols,
|
|
125
125
|
};
|
|
126
126
|
},
|
|
127
|
-
[width, height, getLogoSize, getInputHeight, getIconSize, getMaxWidth, getFontSize, getGridCols],
|
|
127
|
+
[width, height, insets, getLogoSize, getInputHeight, getIconSize, getMaxWidth, getFontSize, getGridCols],
|
|
128
128
|
);
|
|
129
129
|
|
|
130
130
|
return responsiveValues;
|
|
@@ -99,16 +99,24 @@ export function usePersistentCache<T>(
|
|
|
99
99
|
// Track if component is mounted to prevent state updates after unmount
|
|
100
100
|
const isMountedRef = useRef(true);
|
|
101
101
|
|
|
102
|
+
// Stabilize actions to prevent circular dependency
|
|
103
|
+
const stableActionsRef = useRef(actions);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
stableActionsRef.current = actions;
|
|
106
|
+
});
|
|
107
|
+
|
|
102
108
|
const loadFromStorage = useCallback(async () => {
|
|
109
|
+
const currentActions = stableActionsRef.current;
|
|
110
|
+
|
|
103
111
|
if (!enabled) {
|
|
104
112
|
if (isMountedRef.current) {
|
|
105
|
-
|
|
113
|
+
currentActions.setLoading(false);
|
|
106
114
|
}
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
if (isMountedRef.current) {
|
|
111
|
-
|
|
119
|
+
currentActions.setLoading(true);
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
try {
|
|
@@ -117,35 +125,35 @@ export function usePersistentCache<T>(
|
|
|
117
125
|
if (isMountedRef.current) {
|
|
118
126
|
if (cached) {
|
|
119
127
|
const expired = isCacheExpired(cached, version);
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
currentActions.setData(cached.value);
|
|
129
|
+
currentActions.setExpired(expired);
|
|
122
130
|
} else {
|
|
123
|
-
|
|
131
|
+
currentActions.clearData();
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
} catch {
|
|
127
135
|
if (isMountedRef.current) {
|
|
128
|
-
|
|
136
|
+
currentActions.clearData();
|
|
129
137
|
}
|
|
130
138
|
} finally {
|
|
131
139
|
if (isMountedRef.current) {
|
|
132
|
-
|
|
140
|
+
currentActions.setLoading(false);
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
|
-
}, [key, version, enabled,
|
|
143
|
+
}, [key, version, enabled, cacheOps]); // Removed actions from dependencies
|
|
136
144
|
|
|
137
145
|
const setData = useCallback(
|
|
138
146
|
async (value: T) => {
|
|
139
147
|
await cacheOps.saveToStorage(key, value, { ttl, version, enabled });
|
|
140
|
-
|
|
148
|
+
stableActionsRef.current.setData(value);
|
|
141
149
|
},
|
|
142
|
-
[key, ttl, version, enabled,
|
|
150
|
+
[key, ttl, version, enabled, cacheOps],
|
|
143
151
|
);
|
|
144
152
|
|
|
145
153
|
const clearData = useCallback(async () => {
|
|
146
154
|
await cacheOps.clearFromStorage(key, enabled);
|
|
147
|
-
|
|
148
|
-
}, [key, enabled,
|
|
155
|
+
stableActionsRef.current.clearData();
|
|
156
|
+
}, [key, enabled, cacheOps]);
|
|
149
157
|
|
|
150
158
|
const refresh = useCallback(async () => {
|
|
151
159
|
await loadFromStorage();
|
|
@@ -30,6 +30,13 @@ export function usePrefetchQuery<
|
|
|
30
30
|
const queryClient = useQueryClient();
|
|
31
31
|
const prefetchingRef = useRef(new Set<TVariables>());
|
|
32
32
|
|
|
33
|
+
// Cleanup on unmount
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
return () => {
|
|
36
|
+
prefetchingRef.current.clear();
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
33
40
|
return useCallback(
|
|
34
41
|
async (variables: TVariables) => {
|
|
35
42
|
if (prefetchingRef.current.has(variables)) return;
|
|
@@ -62,6 +69,13 @@ export function usePrefetchInfiniteQuery<
|
|
|
62
69
|
const queryClient = useQueryClient();
|
|
63
70
|
const hasPrefetchedRef = useRef(false);
|
|
64
71
|
|
|
72
|
+
// Cleanup on unmount
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
hasPrefetchedRef.current = false;
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
65
79
|
return useCallback(async () => {
|
|
66
80
|
if (hasPrefetchedRef.current) return;
|
|
67
81
|
|
|
@@ -20,6 +20,8 @@ interface ThemeState {
|
|
|
20
20
|
defaultThemeMode: ThemeMode;
|
|
21
21
|
isDark: boolean;
|
|
22
22
|
isInitialized: boolean;
|
|
23
|
+
_updateInProgress: boolean;
|
|
24
|
+
_initInProgress: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
interface ThemeActions {
|
|
@@ -32,9 +34,6 @@ interface ThemeActions {
|
|
|
32
34
|
initialize: () => Promise<void>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
let themeUpdateInProgress = false;
|
|
36
|
-
let themeInitInProgress = false;
|
|
37
|
-
|
|
38
37
|
export const useTheme = createStore<ThemeState, ThemeActions>({
|
|
39
38
|
name: 'theme-store',
|
|
40
39
|
initialState: {
|
|
@@ -45,14 +44,16 @@ export const useTheme = createStore<ThemeState, ThemeActions>({
|
|
|
45
44
|
defaultThemeMode: 'dark',
|
|
46
45
|
isDark: true,
|
|
47
46
|
isInitialized: false,
|
|
47
|
+
_updateInProgress: false,
|
|
48
|
+
_initInProgress: false,
|
|
48
49
|
},
|
|
49
50
|
persist: false,
|
|
50
51
|
actions: (set, get) => ({
|
|
51
52
|
initialize: async () => {
|
|
52
|
-
const { isInitialized, customColors: currentColors, defaultThemeMode } = get();
|
|
53
|
-
if (isInitialized ||
|
|
53
|
+
const { isInitialized, _initInProgress, customColors: currentColors, defaultThemeMode } = get();
|
|
54
|
+
if (isInitialized || _initInProgress) return;
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
set({ _initInProgress: true });
|
|
56
57
|
|
|
57
58
|
try {
|
|
58
59
|
const [savedMode, savedColors] = await Promise.all([
|
|
@@ -77,16 +78,17 @@ export const useTheme = createStore<ThemeState, ThemeActions>({
|
|
|
77
78
|
dsTheme.setThemeMode(mode);
|
|
78
79
|
dsTheme.setCustomColors(colors);
|
|
79
80
|
} catch {
|
|
80
|
-
set({ isInitialized: true });
|
|
81
|
+
set({ isInitialized: true, _initInProgress: false });
|
|
81
82
|
useDesignSystemTheme.getState().setThemeMode(defaultThemeMode);
|
|
82
83
|
} finally {
|
|
83
|
-
|
|
84
|
+
set({ _initInProgress: false });
|
|
84
85
|
}
|
|
85
86
|
},
|
|
86
87
|
|
|
87
88
|
setThemeMode: async (mode: ThemeMode) => {
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const { _updateInProgress } = get();
|
|
90
|
+
if (_updateInProgress) return;
|
|
91
|
+
set({ _updateInProgress: true });
|
|
90
92
|
|
|
91
93
|
try {
|
|
92
94
|
const theme = mode === 'light' ? lightTheme : darkTheme;
|
|
@@ -96,7 +98,7 @@ export const useTheme = createStore<ThemeState, ThemeActions>({
|
|
|
96
98
|
} catch {
|
|
97
99
|
// Silent failure
|
|
98
100
|
} finally {
|
|
99
|
-
|
|
101
|
+
set({ _updateInProgress: false });
|
|
100
102
|
}
|
|
101
103
|
},
|
|
102
104
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TimezoneCalendarDay } from '../../domain/entities/Timezone';
|
|
2
|
+
import { parseDate } from '../utils/TimezoneParsers';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* CalendarManager
|
|
@@ -123,8 +124,7 @@ export class CalendarManager {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
parse(date: Date | string | number): Date {
|
|
126
|
-
|
|
127
|
-
return new Date(date);
|
|
127
|
+
return parseDate(date);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
isValid(date: Date | string | number): boolean {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* DateFormatter
|
|
3
3
|
* Handles locale-aware formatting of dates and times
|
|
4
4
|
*/
|
|
5
|
+
import { parseDate } from '../utils/TimezoneParsers';
|
|
6
|
+
|
|
5
7
|
export class DateFormatter {
|
|
6
8
|
formatDate(
|
|
7
9
|
date: Date | string | number,
|
|
@@ -98,8 +100,7 @@ export class DateFormatter {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
parse(date: Date | string | number): Date {
|
|
101
|
-
|
|
102
|
-
return new Date(date);
|
|
103
|
+
return parseDate(date);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
formatDuration(milliseconds: number): string {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timezone Parsers Utility
|
|
3
|
+
*
|
|
4
|
+
* Shared parsing functions for timezone services.
|
|
5
|
+
* Extracted from duplicate methods across DateFormatter, CalendarManager,
|
|
6
|
+
* BusinessCalendarManager, DateRangeUtils, and DateComparisonUtils.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse date from various input types
|
|
11
|
+
* Ensures a Date object is returned from Date, string, or number input
|
|
12
|
+
*/
|
|
13
|
+
export function parseDate(date: Date | string | number): Date {
|
|
14
|
+
if (date instanceof Date) return new Date(date.getTime());
|
|
15
|
+
return new Date(date);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse timezone offset string to number
|
|
20
|
+
* @param offset - Offset string (e.g., "+05:30", "-08:00")
|
|
21
|
+
* @returns Offset in minutes
|
|
22
|
+
*/
|
|
23
|
+
export function parseTimezoneOffset(offset: string): number {
|
|
24
|
+
const sign = offset[0] === '-' ? -1 : 1;
|
|
25
|
+
const [hours, minutes] = offset.slice(1).split(':').map(Number);
|
|
26
|
+
return sign * (hours * 60 + (minutes || 0));
|
|
27
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @layer presentation/hooks
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
11
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
12
12
|
import { SharingService } from '../../infrastructure/services/SharingService';
|
|
13
13
|
import type { ShareOptions } from '../../domain/entities/Share';
|
|
14
14
|
|
|
@@ -47,29 +47,40 @@ export const useSharing = () => {
|
|
|
47
47
|
const [isSharing, setIsSharing] = useState(false);
|
|
48
48
|
const [error, setError] = useState<string | null>(null);
|
|
49
49
|
|
|
50
|
+
// Track mounted state to prevent setState on unmounted component
|
|
51
|
+
const isMountedRef = useRef(true);
|
|
52
|
+
|
|
50
53
|
/**
|
|
51
54
|
* Check sharing availability on mount
|
|
52
55
|
*/
|
|
53
56
|
useEffect(() => {
|
|
54
57
|
const checkAvailability = async () => {
|
|
55
58
|
const available = await SharingService.isAvailable();
|
|
56
|
-
|
|
59
|
+
if (isMountedRef.current) {
|
|
60
|
+
setIsAvailable(available);
|
|
61
|
+
}
|
|
57
62
|
};
|
|
58
63
|
|
|
59
64
|
checkAvailability();
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
isMountedRef.current = false;
|
|
68
|
+
};
|
|
60
69
|
}, []);
|
|
61
70
|
|
|
62
71
|
/**
|
|
63
72
|
* Share a file via system share sheet
|
|
64
73
|
*/
|
|
65
74
|
const share = useCallback(async (uri: string, options?: ShareOptions): Promise<boolean> => {
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
if (isMountedRef.current) {
|
|
76
|
+
setIsSharing(true);
|
|
77
|
+
setError(null);
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
try {
|
|
70
81
|
const result = await SharingService.shareFile(uri, options);
|
|
71
82
|
|
|
72
|
-
if (!result.success) {
|
|
83
|
+
if (!result.success && isMountedRef.current) {
|
|
73
84
|
setError(result.error || 'Failed to share file');
|
|
74
85
|
return false;
|
|
75
86
|
}
|
|
@@ -77,10 +88,14 @@ export const useSharing = () => {
|
|
|
77
88
|
return true;
|
|
78
89
|
} catch (err) {
|
|
79
90
|
const errorMessage = err instanceof Error ? err.message : 'Failed to share file';
|
|
80
|
-
|
|
91
|
+
if (isMountedRef.current) {
|
|
92
|
+
setError(errorMessage);
|
|
93
|
+
}
|
|
81
94
|
return false;
|
|
82
95
|
} finally {
|
|
83
|
-
|
|
96
|
+
if (isMountedRef.current) {
|
|
97
|
+
setIsSharing(false);
|
|
98
|
+
}
|
|
84
99
|
}
|
|
85
100
|
}, []);
|
|
86
101
|
|
|
@@ -89,13 +104,15 @@ export const useSharing = () => {
|
|
|
89
104
|
*/
|
|
90
105
|
const shareWithAutoType = useCallback(
|
|
91
106
|
async (uri: string, filename: string, dialogTitle?: string): Promise<boolean> => {
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
if (isMountedRef.current) {
|
|
108
|
+
setIsSharing(true);
|
|
109
|
+
setError(null);
|
|
110
|
+
}
|
|
94
111
|
|
|
95
112
|
try {
|
|
96
113
|
const result = await SharingService.shareWithAutoType(uri, filename, dialogTitle);
|
|
97
114
|
|
|
98
|
-
if (!result.success) {
|
|
115
|
+
if (!result.success && isMountedRef.current) {
|
|
99
116
|
setError(result.error || 'Failed to share file');
|
|
100
117
|
return false;
|
|
101
118
|
}
|
|
@@ -103,10 +120,14 @@ export const useSharing = () => {
|
|
|
103
120
|
return true;
|
|
104
121
|
} catch (err) {
|
|
105
122
|
const errorMessage = err instanceof Error ? err.message : 'Failed to share file';
|
|
106
|
-
|
|
123
|
+
if (isMountedRef.current) {
|
|
124
|
+
setError(errorMessage);
|
|
125
|
+
}
|
|
107
126
|
return false;
|
|
108
127
|
} finally {
|
|
109
|
-
|
|
128
|
+
if (isMountedRef.current) {
|
|
129
|
+
setIsSharing(false);
|
|
130
|
+
}
|
|
110
131
|
}
|
|
111
132
|
},
|
|
112
133
|
[]
|
|
@@ -117,13 +138,15 @@ export const useSharing = () => {
|
|
|
117
138
|
*/
|
|
118
139
|
const shareMultiple = useCallback(
|
|
119
140
|
async (uris: string[], options?: ShareOptions): Promise<boolean> => {
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
if (isMountedRef.current) {
|
|
142
|
+
setIsSharing(true);
|
|
143
|
+
setError(null);
|
|
144
|
+
}
|
|
122
145
|
|
|
123
146
|
try {
|
|
124
147
|
const result = await SharingService.shareMultipleFiles(uris, options);
|
|
125
148
|
|
|
126
|
-
if (!result.success) {
|
|
149
|
+
if (!result.success && isMountedRef.current) {
|
|
127
150
|
setError(result.error || 'Failed to share files');
|
|
128
151
|
return false;
|
|
129
152
|
}
|
|
@@ -131,10 +154,14 @@ export const useSharing = () => {
|
|
|
131
154
|
return true;
|
|
132
155
|
} catch (err) {
|
|
133
156
|
const errorMessage = err instanceof Error ? err.message : 'Failed to share files';
|
|
134
|
-
|
|
157
|
+
if (isMountedRef.current) {
|
|
158
|
+
setError(errorMessage);
|
|
159
|
+
}
|
|
135
160
|
return false;
|
|
136
161
|
} finally {
|
|
137
|
-
|
|
162
|
+
if (isMountedRef.current) {
|
|
163
|
+
setIsSharing(false);
|
|
164
|
+
}
|
|
138
165
|
}
|
|
139
166
|
},
|
|
140
167
|
[]
|