@umituz/react-native-design-system 4.23.113 → 4.23.115
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 +37 -9
- package/src/filesystem/infrastructure/services/download.service.ts +62 -11
- package/src/filesystem/infrastructure/services/file-manager.service.ts +42 -11
- package/src/filesystem/infrastructure/services/file-writer.service.ts +8 -3
- package/src/media/infrastructure/services/MediaPickerService.ts +32 -8
- package/src/media/infrastructure/services/MediaSaveService.ts +7 -2
- 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/services/api/ApiClient.ts +37 -6
- 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/src/utils/async/index.ts +12 -0
- package/src/utils/async/retryWithBackoff.ts +177 -0
- package/src/utils/errors/DesignSystemError.ts +117 -0
- package/src/utils/errors/ErrorHandler.ts +137 -0
- package/src/utils/errors/index.ts +7 -0
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.115",
|
|
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
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { Directory, Paths } from "expo-file-system";
|
|
7
7
|
import type { DirectoryType } from "../../domain/entities/File";
|
|
8
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Create directory
|
|
@@ -12,10 +13,19 @@ import type { DirectoryType } from "../../domain/entities/File";
|
|
|
12
13
|
export async function createDirectory(uri: string): Promise<boolean> {
|
|
13
14
|
try {
|
|
14
15
|
const dir = new Directory(uri);
|
|
15
|
-
dir.create({ intermediates: true, idempotent: true });
|
|
16
|
+
await dir.create({ intermediates: true, idempotent: true });
|
|
16
17
|
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const handled = ErrorHandler.handleAndLog(
|
|
20
|
+
error,
|
|
21
|
+
'createDirectory',
|
|
22
|
+
{ uri }
|
|
23
|
+
);
|
|
24
|
+
throw ErrorHandler.create(
|
|
25
|
+
`Failed to create directory: ${handled.message}`,
|
|
26
|
+
ErrorCodes.DIRECTORY_CREATE_ERROR,
|
|
27
|
+
{ uri, originalError: handled }
|
|
28
|
+
);
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
|
|
@@ -25,10 +35,19 @@ export async function createDirectory(uri: string): Promise<boolean> {
|
|
|
25
35
|
export async function listDirectory(uri: string): Promise<string[]> {
|
|
26
36
|
try {
|
|
27
37
|
const dir = new Directory(uri);
|
|
28
|
-
const items = dir.list();
|
|
38
|
+
const items = await dir.list();
|
|
29
39
|
return items.map((item) => item.uri);
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const handled = ErrorHandler.handleAndLog(
|
|
42
|
+
error,
|
|
43
|
+
'listDirectory',
|
|
44
|
+
{ uri }
|
|
45
|
+
);
|
|
46
|
+
throw ErrorHandler.create(
|
|
47
|
+
`Failed to list directory contents: ${handled.message}`,
|
|
48
|
+
ErrorCodes.FILE_READ_ERROR,
|
|
49
|
+
{ uri, originalError: handled }
|
|
50
|
+
);
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
|
|
@@ -43,10 +62,19 @@ export function getDirectoryPath(type: DirectoryType): string {
|
|
|
43
62
|
case "cacheDirectory":
|
|
44
63
|
return Paths.cache.uri;
|
|
45
64
|
default:
|
|
46
|
-
|
|
65
|
+
throw ErrorHandler.create(
|
|
66
|
+
`Unknown directory type: ${type}`,
|
|
67
|
+
ErrorCodes.INVALID_INPUT,
|
|
68
|
+
{ type }
|
|
69
|
+
);
|
|
47
70
|
}
|
|
48
|
-
} catch {
|
|
49
|
-
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const handled = ErrorHandler.handleAndLog(
|
|
73
|
+
error,
|
|
74
|
+
'getDirectoryPath',
|
|
75
|
+
{ type }
|
|
76
|
+
);
|
|
77
|
+
throw handled;
|
|
50
78
|
}
|
|
51
79
|
}
|
|
52
80
|
|
|
@@ -7,6 +7,8 @@ import type { FileOperationResult } from "../../domain/entities/File";
|
|
|
7
7
|
import { FileUtils } from "../../domain/entities/File";
|
|
8
8
|
import { SUPPORTED_DOWNLOAD_EXTENSIONS, DEFAULT_DOWNLOAD_EXTENSION } from "./download.constants";
|
|
9
9
|
import type { DownloadProgressCallback, DownloadWithProgressResult } from "./download.types";
|
|
10
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
11
|
+
import { retryWithBackoff, isNetworkError } from "../../../utils/async";
|
|
10
12
|
|
|
11
13
|
const hashUrl = (url: string) => {
|
|
12
14
|
let hash = 0;
|
|
@@ -25,9 +27,29 @@ const getCacheUri = (url: string, dir: string) => FileUtils.joinPaths(dir, `cach
|
|
|
25
27
|
export async function downloadFile(url: string, dest?: string): Promise<FileOperationResult> {
|
|
26
28
|
try {
|
|
27
29
|
const destination = dest ? new File(dest) : new File(Paths.document, FileUtils.generateUniqueFilename("download"));
|
|
28
|
-
|
|
30
|
+
|
|
31
|
+
// Retry download with exponential backoff
|
|
32
|
+
const res = await retryWithBackoff(
|
|
33
|
+
() => File.downloadFileAsync(url, destination, { idempotent: true }),
|
|
34
|
+
{
|
|
35
|
+
maxRetries: 3,
|
|
36
|
+
baseDelay: 1000,
|
|
37
|
+
shouldRetry: (error) => isNetworkError(error as Error),
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
29
41
|
return { success: true, uri: res.uri };
|
|
30
|
-
} catch (
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const handled = ErrorHandler.handleAndLog(
|
|
44
|
+
error,
|
|
45
|
+
'downloadFile',
|
|
46
|
+
{ url, dest }
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
error: handled.getUserMessage(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
export async function downloadFileWithProgress(
|
|
@@ -38,13 +60,19 @@ export async function downloadFileWithProgress(
|
|
|
38
60
|
): Promise<DownloadWithProgressResult> {
|
|
39
61
|
try {
|
|
40
62
|
const dir = new Directory(cacheDir);
|
|
41
|
-
if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
|
|
63
|
+
if (!dir.exists) await dir.create({ intermediates: true, idempotent: true });
|
|
42
64
|
|
|
43
65
|
const destUri = getCacheUri(url, cacheDir);
|
|
44
66
|
if (new File(destUri).exists) return { success: true, uri: destUri, fromCache: true };
|
|
45
67
|
|
|
46
68
|
const response = await fetch(url, { signal });
|
|
47
|
-
if (!response.ok)
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw ErrorHandler.create(
|
|
71
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
72
|
+
ErrorCodes.NETWORK_ERROR,
|
|
73
|
+
{ url, status: response.status }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
48
76
|
|
|
49
77
|
const totalBytes = parseInt(response.headers.get("content-length") || "0", 10);
|
|
50
78
|
if (!response.body) return { ...(await downloadFile(url, destUri)), fromCache: false };
|
|
@@ -57,7 +85,11 @@ export async function downloadFileWithProgress(
|
|
|
57
85
|
while (true) {
|
|
58
86
|
if (signal?.aborted) {
|
|
59
87
|
await reader.cancel();
|
|
60
|
-
throw
|
|
88
|
+
throw ErrorHandler.create(
|
|
89
|
+
'Download aborted by user',
|
|
90
|
+
ErrorCodes.NETWORK_ERROR,
|
|
91
|
+
{ url }
|
|
92
|
+
);
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
const { done, value } = await reader.read();
|
|
@@ -70,19 +102,38 @@ export async function downloadFileWithProgress(
|
|
|
70
102
|
const all = new Uint8Array(received);
|
|
71
103
|
let pos = 0;
|
|
72
104
|
for (const c of chunks) { all.set(c, pos); pos += c.length; }
|
|
73
|
-
new File(destUri).write(all);
|
|
105
|
+
await new File(destUri).write(all);
|
|
74
106
|
|
|
75
107
|
return { success: true, uri: destUri, fromCache: false };
|
|
76
108
|
} finally {
|
|
77
109
|
reader.releaseLock();
|
|
78
110
|
}
|
|
79
|
-
} catch (
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const handled = ErrorHandler.handleAndLog(
|
|
113
|
+
error,
|
|
114
|
+
'downloadFileWithProgress',
|
|
115
|
+
{ url, cacheDir }
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: handled.getUserMessage(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
export const isUrlCached = (url: string, dir: string) => new File(getCacheUri(url, dir)).exists;
|
|
83
125
|
export const getCachedFileUri = (url: string, dir: string) => isUrlCached(url, dir) ? getCacheUri(url, dir) : null;
|
|
84
|
-
export const deleteCachedFile = (url: string, dir: string) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
export const deleteCachedFile = async (url: string, dir: string): Promise<boolean> => {
|
|
127
|
+
try {
|
|
128
|
+
const f = new File(getCacheUri(url, dir));
|
|
129
|
+
if (f.exists) await f.delete();
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
ErrorHandler.handleAndLog(
|
|
133
|
+
error,
|
|
134
|
+
'deleteCachedFile',
|
|
135
|
+
{ url, dir }
|
|
136
|
+
);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
88
139
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { File, Directory } from "expo-file-system";
|
|
7
7
|
import type { FileOperationResult } from "../../domain/entities/File";
|
|
8
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Delete file or directory
|
|
@@ -15,27 +16,47 @@ export async function deleteFile(uri: string): Promise<boolean> {
|
|
|
15
16
|
try {
|
|
16
17
|
const file = new File(uri);
|
|
17
18
|
if (file.exists) {
|
|
18
|
-
file.delete();
|
|
19
|
+
await file.delete();
|
|
19
20
|
return true;
|
|
20
21
|
}
|
|
21
|
-
} catch {
|
|
22
|
+
} catch (fileError) {
|
|
22
23
|
// Not a file, try as directory
|
|
24
|
+
if (__DEV__) {
|
|
25
|
+
console.log('[deleteFile] Not a file, trying as directory:', uri);
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Try as directory
|
|
26
30
|
try {
|
|
27
31
|
const dir = new Directory(uri);
|
|
28
32
|
if (dir.exists) {
|
|
29
|
-
dir.delete();
|
|
33
|
+
await dir.delete();
|
|
30
34
|
return true;
|
|
31
35
|
}
|
|
32
|
-
} catch {
|
|
36
|
+
} catch (dirError) {
|
|
33
37
|
// Not a directory either
|
|
38
|
+
if (__DEV__) {
|
|
39
|
+
console.log('[deleteFile] Not a directory:', uri);
|
|
40
|
+
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
// File/directory doesn't exist
|
|
44
|
+
throw ErrorHandler.create(
|
|
45
|
+
`File or directory not found: ${uri}`,
|
|
46
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
47
|
+
{ uri }
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const handled = ErrorHandler.handleAndLog(
|
|
51
|
+
error,
|
|
52
|
+
'deleteFile',
|
|
53
|
+
{ uri }
|
|
54
|
+
);
|
|
55
|
+
throw ErrorHandler.create(
|
|
56
|
+
`Failed to delete file: ${handled.message}`,
|
|
57
|
+
ErrorCodes.FILE_DELETE_ERROR,
|
|
58
|
+
{ uri, originalError: handled }
|
|
59
|
+
);
|
|
39
60
|
}
|
|
40
61
|
}
|
|
41
62
|
|
|
@@ -49,12 +70,17 @@ export async function copyFile(
|
|
|
49
70
|
try {
|
|
50
71
|
const sourceFile = new File(sourceUri);
|
|
51
72
|
const destination = new File(destinationUri);
|
|
52
|
-
sourceFile.copy(destination);
|
|
73
|
+
await sourceFile.copy(destination);
|
|
53
74
|
return { success: true, uri: destinationUri };
|
|
54
75
|
} catch (error) {
|
|
76
|
+
const handled = ErrorHandler.handleAndLog(
|
|
77
|
+
error,
|
|
78
|
+
'copyFile',
|
|
79
|
+
{ sourceUri, destinationUri }
|
|
80
|
+
);
|
|
55
81
|
return {
|
|
56
82
|
success: false,
|
|
57
|
-
error:
|
|
83
|
+
error: handled.getUserMessage(),
|
|
58
84
|
};
|
|
59
85
|
}
|
|
60
86
|
}
|
|
@@ -69,12 +95,17 @@ export async function moveFile(
|
|
|
69
95
|
try {
|
|
70
96
|
const sourceFile = new File(sourceUri);
|
|
71
97
|
const destination = new File(destinationUri);
|
|
72
|
-
sourceFile.move(destination);
|
|
98
|
+
await sourceFile.move(destination);
|
|
73
99
|
return { success: true, uri: destinationUri };
|
|
74
100
|
} catch (error) {
|
|
101
|
+
const handled = ErrorHandler.handleAndLog(
|
|
102
|
+
error,
|
|
103
|
+
'moveFile',
|
|
104
|
+
{ sourceUri, destinationUri }
|
|
105
|
+
);
|
|
75
106
|
return {
|
|
76
107
|
success: false,
|
|
77
|
-
error:
|
|
108
|
+
error: handled.getUserMessage(),
|
|
78
109
|
};
|
|
79
110
|
}
|
|
80
111
|
}
|