@umituz/react-native-design-system 4.23.82 → 4.23.84
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/input/hooks/useInputState.ts +2 -4
- package/src/image/presentation/components/editor/text-editor/TextTransformTab.tsx +40 -152
- package/src/image/presentation/components/editor/text-editor/components/TransformButtonRow.tsx +124 -0
- package/src/layouts/Grid/Grid.tsx +16 -11
- package/src/media/domain/utils/FileValidator.ts +156 -0
- package/src/media/infrastructure/services/MediaPickerService.ts +18 -57
- package/src/media/infrastructure/utils/PermissionManager.ts +92 -0
- package/src/media/infrastructure/utils/file-media-utils.ts +25 -8
- package/src/media/presentation/hooks/useMedia.ts +5 -4
- package/src/molecules/alerts/AlertBanner.tsx +9 -25
- package/src/molecules/alerts/AlertInline.tsx +4 -23
- package/src/molecules/alerts/AlertModal.tsx +4 -11
- package/src/molecules/alerts/AlertToast.tsx +14 -13
- package/src/molecules/alerts/utils/alertUtils.ts +133 -0
- package/src/molecules/calendar/infrastructure/storage/CalendarStore.ts +65 -25
- package/src/molecules/countdown/hooks/useCountdown.ts +13 -5
- package/src/molecules/swipe-actions/domain/entities/SwipeAction.ts +15 -123
- package/src/molecules/swipe-actions/domain/utils/swipeActionHelpers.ts +109 -0
- package/src/molecules/swipe-actions/domain/utils/swipeActionValidator.ts +54 -0
- package/src/molecules/swipe-actions/presentation/components/SwipeActionButton.tsx +24 -6
- package/src/offline/presentation/hooks/useOffline.ts +2 -1
- package/src/storage/domain/utils/devUtils.ts +7 -6
- package/src/storage/infrastructure/adapters/StorageService.ts +0 -9
- package/src/storage/infrastructure/repositories/BaseStorageOperations.ts +0 -3
- package/src/tanstack/domain/utils/MetricsCalculator.ts +103 -0
- package/src/tanstack/infrastructure/monitoring/DevMonitor.ts +35 -29
- package/src/timezone/infrastructure/utils/SimpleCache.ts +24 -2
- package/src/molecules/alerts/utils/alertToastHelpers.ts +0 -70
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.84",
|
|
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",
|
|
@@ -24,12 +24,10 @@ export const useInputState = ({
|
|
|
24
24
|
value = '',
|
|
25
25
|
onChangeText,
|
|
26
26
|
secureTextEntry = false,
|
|
27
|
-
showPasswordToggle = false,
|
|
27
|
+
showPasswordToggle: _showPasswordToggle = false,
|
|
28
28
|
maxLength,
|
|
29
|
-
showCharacterCount = false,
|
|
29
|
+
showCharacterCount: _showCharacterCount = false,
|
|
30
30
|
}: UseInputStateProps = {}): UseInputStateReturn => {
|
|
31
|
-
void showPasswordToggle;
|
|
32
|
-
void showCharacterCount;
|
|
33
31
|
const [localValue, setLocalValue] = useState(value);
|
|
34
32
|
const [isFocused, setIsFocused] = useState(false);
|
|
35
33
|
const [isPasswordVisible, setIsPasswordVisible] = useState(!secureTextEntry);
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import React from "react";
|
|
6
|
-
import { View
|
|
7
|
-
import { AtomicText, AtomicIcon } from "../../../../../atoms";
|
|
6
|
+
import { View } from "react-native";
|
|
8
7
|
import { useAppDesignTokens } from "../../../../../theme/hooks/useAppDesignTokens";
|
|
9
|
-
import {
|
|
8
|
+
import type { TabProps } from "./TextEditorTabs.styles";
|
|
9
|
+
import { TransformButtonRow, DeleteButton } from "./components/TransformButtonRow";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_SCALES = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
12
12
|
const DEFAULT_ROTATIONS = [0, 45, 90, 135, 180, 225, 270, 315];
|
|
@@ -33,160 +33,48 @@ export const TextTransformTab: React.FC<TextTransformTabProps> = ({
|
|
|
33
33
|
}) => {
|
|
34
34
|
const tokens = useAppDesignTokens();
|
|
35
35
|
|
|
36
|
+
const scaleButtons = DEFAULT_SCALES.map((s) => ({
|
|
37
|
+
value: s,
|
|
38
|
+
label: s.toFixed(1) + "x",
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const rotationButtons = DEFAULT_ROTATIONS.map((r) => ({
|
|
42
|
+
value: r,
|
|
43
|
+
label: r + "°",
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const opacityButtons = DEFAULT_OPACITIES.map((o) => ({
|
|
47
|
+
value: o,
|
|
48
|
+
label: Math.round(o * 100) + "%",
|
|
49
|
+
}));
|
|
50
|
+
|
|
36
51
|
return (
|
|
37
52
|
<View style={{ gap: tokens.spacing.xl }}>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
>
|
|
46
|
-
Scale: {scale.toFixed(2)}x
|
|
47
|
-
</AtomicText>
|
|
48
|
-
<ScrollView
|
|
49
|
-
horizontal
|
|
50
|
-
showsHorizontalScrollIndicator={false}
|
|
51
|
-
contentContainerStyle={{ gap: tokens.spacing.sm }}
|
|
52
|
-
>
|
|
53
|
-
{DEFAULT_SCALES.map((s) => (
|
|
54
|
-
<TouchableOpacity
|
|
55
|
-
key={s}
|
|
56
|
-
onPress={() => setScale(s)}
|
|
57
|
-
style={[
|
|
58
|
-
textEditorStyles.transformButton,
|
|
59
|
-
{
|
|
60
|
-
paddingHorizontal: tokens.spacing.md,
|
|
61
|
-
paddingVertical: tokens.spacing.sm,
|
|
62
|
-
borderRadius: tokens.borders.radius.md,
|
|
63
|
-
borderWidth: 1,
|
|
64
|
-
borderColor: scale === s ? tokens.colors.primary : tokens.colors.border,
|
|
65
|
-
backgroundColor: scale === s ? tokens.colors.primary : tokens.colors.surface,
|
|
66
|
-
},
|
|
67
|
-
]}
|
|
68
|
-
>
|
|
69
|
-
<AtomicText
|
|
70
|
-
style={{ color: scale === s ? "white" : tokens.colors.textPrimary }}
|
|
71
|
-
>
|
|
72
|
-
{s.toFixed(1)}x
|
|
73
|
-
</AtomicText>
|
|
74
|
-
</TouchableOpacity>
|
|
75
|
-
))}
|
|
76
|
-
</ScrollView>
|
|
77
|
-
</View>
|
|
53
|
+
<TransformButtonRow
|
|
54
|
+
title="Scale"
|
|
55
|
+
buttons={scaleButtons}
|
|
56
|
+
selectedValue={scale}
|
|
57
|
+
onSelect={setScale}
|
|
58
|
+
formatTitle={(v) => v.toFixed(2) + "x"}
|
|
59
|
+
/>
|
|
78
60
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
>
|
|
87
|
-
Rotation: {Math.round(rotation)}°
|
|
88
|
-
</AtomicText>
|
|
89
|
-
<ScrollView
|
|
90
|
-
horizontal
|
|
91
|
-
showsHorizontalScrollIndicator={false}
|
|
92
|
-
contentContainerStyle={{ gap: tokens.spacing.sm }}
|
|
93
|
-
>
|
|
94
|
-
{DEFAULT_ROTATIONS.map((r) => (
|
|
95
|
-
<TouchableOpacity
|
|
96
|
-
key={r}
|
|
97
|
-
onPress={() => setRotation(r)}
|
|
98
|
-
style={[
|
|
99
|
-
textEditorStyles.transformButton,
|
|
100
|
-
{
|
|
101
|
-
paddingHorizontal: tokens.spacing.md,
|
|
102
|
-
paddingVertical: tokens.spacing.sm,
|
|
103
|
-
borderRadius: tokens.borders.radius.md,
|
|
104
|
-
borderWidth: 1,
|
|
105
|
-
borderColor: rotation === r ? tokens.colors.primary : tokens.colors.border,
|
|
106
|
-
backgroundColor: rotation === r ? tokens.colors.primary : tokens.colors.surface,
|
|
107
|
-
},
|
|
108
|
-
]}
|
|
109
|
-
>
|
|
110
|
-
<AtomicText
|
|
111
|
-
style={{ color: rotation === r ? "white" : tokens.colors.textPrimary }}
|
|
112
|
-
>
|
|
113
|
-
{r}°
|
|
114
|
-
</AtomicText>
|
|
115
|
-
</TouchableOpacity>
|
|
116
|
-
))}
|
|
117
|
-
</ScrollView>
|
|
118
|
-
</View>
|
|
61
|
+
<TransformButtonRow
|
|
62
|
+
title="Rotation"
|
|
63
|
+
buttons={rotationButtons}
|
|
64
|
+
selectedValue={rotation}
|
|
65
|
+
onSelect={setRotation}
|
|
66
|
+
formatTitle={(v) => Math.round(v) + "°"}
|
|
67
|
+
/>
|
|
119
68
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
>
|
|
128
|
-
Opacity: {(opacity * 100).toFixed(0)}%
|
|
129
|
-
</AtomicText>
|
|
130
|
-
<ScrollView
|
|
131
|
-
horizontal
|
|
132
|
-
showsHorizontalScrollIndicator={false}
|
|
133
|
-
contentContainerStyle={{ gap: tokens.spacing.sm }}
|
|
134
|
-
>
|
|
135
|
-
{DEFAULT_OPACITIES.map((o) => (
|
|
136
|
-
<TouchableOpacity
|
|
137
|
-
key={o}
|
|
138
|
-
onPress={() => setOpacity(o)}
|
|
139
|
-
style={[
|
|
140
|
-
textEditorStyles.transformButton,
|
|
141
|
-
{
|
|
142
|
-
paddingHorizontal: tokens.spacing.md,
|
|
143
|
-
paddingVertical: tokens.spacing.sm,
|
|
144
|
-
borderRadius: tokens.borders.radius.md,
|
|
145
|
-
borderWidth: 1,
|
|
146
|
-
borderColor: opacity === o ? tokens.colors.primary : tokens.colors.border,
|
|
147
|
-
backgroundColor: opacity === o ? tokens.colors.primary : tokens.colors.surface,
|
|
148
|
-
},
|
|
149
|
-
]}
|
|
150
|
-
>
|
|
151
|
-
<AtomicText
|
|
152
|
-
style={{ color: opacity === o ? "white" : tokens.colors.textPrimary }}
|
|
153
|
-
>
|
|
154
|
-
{Math.round(o * 100)}%
|
|
155
|
-
</AtomicText>
|
|
156
|
-
</TouchableOpacity>
|
|
157
|
-
))}
|
|
158
|
-
</ScrollView>
|
|
159
|
-
</View>
|
|
69
|
+
<TransformButtonRow
|
|
70
|
+
title="Opacity"
|
|
71
|
+
buttons={opacityButtons}
|
|
72
|
+
selectedValue={opacity}
|
|
73
|
+
onSelect={setOpacity}
|
|
74
|
+
formatTitle={(v) => (v * 100).toFixed(0) + "%"}
|
|
75
|
+
/>
|
|
160
76
|
|
|
161
|
-
{
|
|
162
|
-
{onDelete && (
|
|
163
|
-
<TouchableOpacity
|
|
164
|
-
onPress={onDelete}
|
|
165
|
-
style={[
|
|
166
|
-
textEditorStyles.deleteButton,
|
|
167
|
-
{
|
|
168
|
-
flexDirection: "row",
|
|
169
|
-
alignItems: "center",
|
|
170
|
-
justifyContent: "center",
|
|
171
|
-
gap: tokens.spacing.sm,
|
|
172
|
-
padding: tokens.spacing.md,
|
|
173
|
-
borderRadius: tokens.borders.radius.md,
|
|
174
|
-
borderWidth: 1,
|
|
175
|
-
borderColor: tokens.colors.error,
|
|
176
|
-
},
|
|
177
|
-
]}
|
|
178
|
-
>
|
|
179
|
-
<AtomicIcon name="trash" size={20} color="error" />
|
|
180
|
-
<AtomicText
|
|
181
|
-
style={{
|
|
182
|
-
...tokens.typography.labelMedium,
|
|
183
|
-
color: tokens.colors.error,
|
|
184
|
-
}}
|
|
185
|
-
>
|
|
186
|
-
Delete Layer
|
|
187
|
-
</AtomicText>
|
|
188
|
-
</TouchableOpacity>
|
|
189
|
-
)}
|
|
77
|
+
{onDelete && <DeleteButton onPress={onDelete} />}
|
|
190
78
|
</View>
|
|
191
79
|
);
|
|
192
80
|
};
|
package/src/image/presentation/components/editor/text-editor/components/TransformButtonRow.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform Button Row Component
|
|
3
|
+
*
|
|
4
|
+
* Reusable horizontal scrollable row of transform buttons.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { ScrollView, TouchableOpacity, View, type StyleProp, type ViewStyle } from "react-native";
|
|
9
|
+
import { AtomicText, AtomicIcon } from "../../../../../../atoms";
|
|
10
|
+
import { useAppDesignTokens } from "../../../../../../theme/hooks/useAppDesignTokens";
|
|
11
|
+
import { textEditorStyles } from "../TextEditorTabs.styles";
|
|
12
|
+
|
|
13
|
+
interface TransformButton {
|
|
14
|
+
value: number;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TransformButtonRowProps {
|
|
19
|
+
title: string;
|
|
20
|
+
buttons: TransformButton[];
|
|
21
|
+
selectedValue: number;
|
|
22
|
+
onSelect: (value: number) => void;
|
|
23
|
+
formatLabel?: (value: number) => string;
|
|
24
|
+
formatTitle?: (selectedValue: number) => string;
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const TransformButtonRow: React.FC<TransformButtonRowProps> = ({
|
|
29
|
+
title,
|
|
30
|
+
buttons,
|
|
31
|
+
selectedValue,
|
|
32
|
+
onSelect,
|
|
33
|
+
formatLabel = (v) => v.toString(),
|
|
34
|
+
formatTitle = (v) => v.toString(),
|
|
35
|
+
style,
|
|
36
|
+
}) => {
|
|
37
|
+
const tokens = useAppDesignTokens();
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={style}>
|
|
41
|
+
<AtomicText
|
|
42
|
+
style={{
|
|
43
|
+
...tokens.typography.labelMedium,
|
|
44
|
+
marginBottom: tokens.spacing.xs,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{title}: {formatTitle(selectedValue)}
|
|
48
|
+
</AtomicText>
|
|
49
|
+
<ScrollView
|
|
50
|
+
horizontal
|
|
51
|
+
showsHorizontalScrollIndicator={false}
|
|
52
|
+
contentContainerStyle={{ gap: tokens.spacing.sm }}
|
|
53
|
+
>
|
|
54
|
+
{buttons.map((button) => {
|
|
55
|
+
const isSelected = selectedValue === button.value;
|
|
56
|
+
return (
|
|
57
|
+
<TouchableOpacity
|
|
58
|
+
key={button.value}
|
|
59
|
+
onPress={() => onSelect(button.value)}
|
|
60
|
+
style={[
|
|
61
|
+
textEditorStyles.transformButton,
|
|
62
|
+
{
|
|
63
|
+
paddingHorizontal: tokens.spacing.md,
|
|
64
|
+
paddingVertical: tokens.spacing.sm,
|
|
65
|
+
borderRadius: tokens.borders.radius.md,
|
|
66
|
+
borderWidth: 1,
|
|
67
|
+
borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
|
|
68
|
+
backgroundColor: isSelected ? tokens.colors.primary : tokens.colors.surface,
|
|
69
|
+
},
|
|
70
|
+
]}
|
|
71
|
+
>
|
|
72
|
+
<AtomicText
|
|
73
|
+
style={{ color: isSelected ? "white" : tokens.colors.textPrimary }}
|
|
74
|
+
>
|
|
75
|
+
{formatLabel(button.value)}
|
|
76
|
+
</AtomicText>
|
|
77
|
+
</TouchableOpacity>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</ScrollView>
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface DeleteButtonProps {
|
|
86
|
+
onPress: () => void;
|
|
87
|
+
label?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const DeleteButton: React.FC<DeleteButtonProps> = ({
|
|
91
|
+
onPress,
|
|
92
|
+
label = "Delete Layer",
|
|
93
|
+
}) => {
|
|
94
|
+
const tokens = useAppDesignTokens();
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<TouchableOpacity
|
|
98
|
+
onPress={onPress}
|
|
99
|
+
style={[
|
|
100
|
+
textEditorStyles.deleteButton,
|
|
101
|
+
{
|
|
102
|
+
flexDirection: "row",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
justifyContent: "center",
|
|
105
|
+
gap: tokens.spacing.sm,
|
|
106
|
+
padding: tokens.spacing.md,
|
|
107
|
+
borderRadius: tokens.borders.radius.md,
|
|
108
|
+
borderWidth: 1,
|
|
109
|
+
borderColor: tokens.colors.error,
|
|
110
|
+
},
|
|
111
|
+
]}
|
|
112
|
+
>
|
|
113
|
+
<AtomicIcon name="trash" size={20} color="error" />
|
|
114
|
+
<AtomicText
|
|
115
|
+
style={{
|
|
116
|
+
...tokens.typography.labelMedium,
|
|
117
|
+
color: tokens.colors.error,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{label}
|
|
121
|
+
</AtomicText>
|
|
122
|
+
</TouchableOpacity>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
@@ -78,17 +78,22 @@ export const Grid: React.FC<GridProps> = ({
|
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
80
|
<View style={[styles.container, style]} testID={testID}>
|
|
81
|
-
{childArray.map((child, index) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
{childArray.map((child, index) => {
|
|
82
|
+
// Use child's key if available, otherwise use index
|
|
83
|
+
const key = (child as React.ReactElement).key || `grid-item-${index}`;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<View
|
|
87
|
+
key={key}
|
|
88
|
+
style={{
|
|
89
|
+
flex: columns ? 1 / columns - 0.01 : undefined,
|
|
90
|
+
minWidth: columns ? `${100 / columns - 1}%` : undefined,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{child}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
92
97
|
</View>
|
|
93
98
|
);
|
|
94
99
|
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Validator
|
|
3
|
+
*
|
|
4
|
+
* File size and format validation utilities for media operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MediaAsset } from "../entities/Media";
|
|
8
|
+
import { MediaValidationError, MEDIA_CONSTANTS } from "../entities/Media";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* File validation options
|
|
12
|
+
*/
|
|
13
|
+
export interface FileValidationOptions {
|
|
14
|
+
maxFileSizeMB?: number;
|
|
15
|
+
allowedFormats?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* File validation result
|
|
20
|
+
*/
|
|
21
|
+
export interface FileValidationResult {
|
|
22
|
+
valid: boolean;
|
|
23
|
+
error?: MediaValidationError;
|
|
24
|
+
errorMessage?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* File validator for media operations
|
|
29
|
+
*/
|
|
30
|
+
export class FileValidator {
|
|
31
|
+
/**
|
|
32
|
+
* Validates file size against maximum limit
|
|
33
|
+
*
|
|
34
|
+
* @param fileSize - File size in bytes
|
|
35
|
+
* @param maxSizeMB - Maximum file size in megabytes
|
|
36
|
+
* @returns Validation result
|
|
37
|
+
*/
|
|
38
|
+
static validateFileSize(
|
|
39
|
+
fileSize: number,
|
|
40
|
+
maxSizeMB: number = MEDIA_CONSTANTS.MAX_IMAGE_SIZE_MB
|
|
41
|
+
): FileValidationResult {
|
|
42
|
+
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
if (fileSize > maxSizeBytes) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
error: MediaValidationError.FILE_TOO_LARGE,
|
|
48
|
+
errorMessage: `File size exceeds ${maxSizeMB}MB limit`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { valid: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validates file format against allowed formats
|
|
57
|
+
*
|
|
58
|
+
* @param fileName - File name to check
|
|
59
|
+
* @param allowedFormats - Array of allowed file extensions
|
|
60
|
+
* @returns Validation result
|
|
61
|
+
*/
|
|
62
|
+
static validateFileFormat(
|
|
63
|
+
fileName: string,
|
|
64
|
+
allowedFormats?: string[]
|
|
65
|
+
): FileValidationResult {
|
|
66
|
+
const formats = allowedFormats ?? [...MEDIA_CONSTANTS.SUPPORTED_IMAGE_FORMATS];
|
|
67
|
+
const fileExtension = fileName
|
|
68
|
+
.substring(fileName.lastIndexOf("."))
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
|
|
71
|
+
if (!formats.includes(fileExtension)) {
|
|
72
|
+
return {
|
|
73
|
+
valid: false,
|
|
74
|
+
error: MediaValidationError.INVALID_FORMAT,
|
|
75
|
+
errorMessage: `File format ${fileExtension} is not supported`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { valid: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validates a media asset
|
|
84
|
+
*
|
|
85
|
+
* @param asset - Media asset to validate
|
|
86
|
+
* @param options - Validation options
|
|
87
|
+
* @returns Validation result
|
|
88
|
+
*/
|
|
89
|
+
static validateAsset(
|
|
90
|
+
asset: MediaAsset,
|
|
91
|
+
options: FileValidationOptions = {}
|
|
92
|
+
): FileValidationResult {
|
|
93
|
+
// Validate file size if present
|
|
94
|
+
if (asset.fileSize !== undefined) {
|
|
95
|
+
const maxSizeMB = options.maxFileSizeMB ?? MEDIA_CONSTANTS.MAX_IMAGE_SIZE_MB;
|
|
96
|
+
const sizeValidation = this.validateFileSize(asset.fileSize, maxSizeMB);
|
|
97
|
+
if (!sizeValidation.valid) {
|
|
98
|
+
return sizeValidation;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate file format if file name is present
|
|
103
|
+
if (asset.fileName && options.allowedFormats) {
|
|
104
|
+
const formatValidation = this.validateFileFormat(
|
|
105
|
+
asset.fileName,
|
|
106
|
+
options.allowedFormats
|
|
107
|
+
);
|
|
108
|
+
if (!formatValidation.valid) {
|
|
109
|
+
return formatValidation;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { valid: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validates multiple media assets
|
|
118
|
+
*
|
|
119
|
+
* @param assets - Array of media assets to validate
|
|
120
|
+
* @param options - Validation options
|
|
121
|
+
* @returns First validation error found, or { valid: true } if all pass
|
|
122
|
+
*/
|
|
123
|
+
static validateAssets(
|
|
124
|
+
assets: MediaAsset[],
|
|
125
|
+
options: FileValidationOptions = {}
|
|
126
|
+
): FileValidationResult {
|
|
127
|
+
for (const asset of assets) {
|
|
128
|
+
const validation = this.validateAsset(asset, options);
|
|
129
|
+
if (!validation.valid) {
|
|
130
|
+
return validation;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { valid: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Formats file size for display
|
|
139
|
+
*
|
|
140
|
+
* @param fileSize - File size in bytes
|
|
141
|
+
* @returns Formatted file size string (e.g., "1.5 MB")
|
|
142
|
+
*/
|
|
143
|
+
static formatFileSize(fileSize: number): string {
|
|
144
|
+
const bytes = fileSize;
|
|
145
|
+
const kilobytes = bytes / 1024;
|
|
146
|
+
const megabytes = kilobytes / 1024;
|
|
147
|
+
|
|
148
|
+
if (megabytes >= 1) {
|
|
149
|
+
return `${megabytes.toFixed(1)} MB`;
|
|
150
|
+
} else if (kilobytes >= 1) {
|
|
151
|
+
return `${kilobytes.toFixed(0)} KB`;
|
|
152
|
+
} else {
|
|
153
|
+
return `${bytes} B`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -12,64 +12,27 @@ import type {
|
|
|
12
12
|
CameraOptions,
|
|
13
13
|
} from "../../domain/entities/Media";
|
|
14
14
|
import {
|
|
15
|
-
MediaLibraryPermission,
|
|
16
15
|
MediaType,
|
|
17
16
|
MediaValidationError,
|
|
18
17
|
MEDIA_CONSTANTS,
|
|
19
18
|
} from "../../domain/entities/Media";
|
|
20
19
|
import {
|
|
21
20
|
mapMediaType,
|
|
22
|
-
mapPermissionStatus,
|
|
23
21
|
mapPickerResult,
|
|
24
22
|
} from "../utils/mediaPickerMappers";
|
|
23
|
+
import { PermissionManager } from "../utils/PermissionManager";
|
|
24
|
+
import { FileValidator } from "../../domain/utils/FileValidator";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Media picker service for selecting images/videos
|
|
28
28
|
*/
|
|
29
29
|
export class MediaPickerService {
|
|
30
|
-
static async requestCameraPermission(): Promise<MediaLibraryPermission> {
|
|
31
|
-
try {
|
|
32
|
-
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
|
33
|
-
return mapPermissionStatus(status);
|
|
34
|
-
} catch {
|
|
35
|
-
return MediaLibraryPermission.DENIED;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
static async requestMediaLibraryPermission(): Promise<MediaLibraryPermission> {
|
|
40
|
-
try {
|
|
41
|
-
const { status } =
|
|
42
|
-
await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
43
|
-
return mapPermissionStatus(status);
|
|
44
|
-
} catch {
|
|
45
|
-
return MediaLibraryPermission.DENIED;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
static async getCameraPermissionStatus(): Promise<MediaLibraryPermission> {
|
|
50
|
-
try {
|
|
51
|
-
const { status } = await ImagePicker.getCameraPermissionsAsync();
|
|
52
|
-
return mapPermissionStatus(status);
|
|
53
|
-
} catch {
|
|
54
|
-
return MediaLibraryPermission.DENIED;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
static async getMediaLibraryPermissionStatus(): Promise<MediaLibraryPermission> {
|
|
59
|
-
try {
|
|
60
|
-
const { status } = await ImagePicker.getMediaLibraryPermissionsAsync();
|
|
61
|
-
return mapPermissionStatus(status);
|
|
62
|
-
} catch {
|
|
63
|
-
return MediaLibraryPermission.DENIED;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
30
|
static async launchCamera(
|
|
68
31
|
options?: CameraOptions
|
|
69
32
|
): Promise<MediaPickerResult> {
|
|
70
33
|
try {
|
|
71
|
-
const permission = await
|
|
72
|
-
if (permission
|
|
34
|
+
const permission = await PermissionManager.requestCameraPermission();
|
|
35
|
+
if (!PermissionManager.isPermissionGranted(permission)) {
|
|
73
36
|
return { canceled: true };
|
|
74
37
|
}
|
|
75
38
|
|
|
@@ -91,8 +54,8 @@ export class MediaPickerService {
|
|
|
91
54
|
options?: CameraOptions
|
|
92
55
|
): Promise<MediaPickerResult> {
|
|
93
56
|
try {
|
|
94
|
-
const permission = await
|
|
95
|
-
if (permission
|
|
57
|
+
const permission = await PermissionManager.requestCameraPermission();
|
|
58
|
+
if (!PermissionManager.isPermissionGranted(permission)) {
|
|
96
59
|
return { canceled: true };
|
|
97
60
|
}
|
|
98
61
|
|
|
@@ -113,9 +76,8 @@ export class MediaPickerService {
|
|
|
113
76
|
options?: MediaPickerOptions
|
|
114
77
|
): Promise<MediaPickerResult> {
|
|
115
78
|
try {
|
|
116
|
-
const permission =
|
|
117
|
-
|
|
118
|
-
if (permission === MediaLibraryPermission.DENIED) {
|
|
79
|
+
const permission = await PermissionManager.requestMediaLibraryPermission();
|
|
80
|
+
if (!PermissionManager.isPermissionGranted(permission)) {
|
|
119
81
|
return {
|
|
120
82
|
canceled: true,
|
|
121
83
|
error: MediaValidationError.PERMISSION_DENIED,
|
|
@@ -138,17 +100,16 @@ export class MediaPickerService {
|
|
|
138
100
|
|
|
139
101
|
// Validate file size if not canceled and has assets
|
|
140
102
|
if (!mappedResult.canceled && mappedResult.assets && mappedResult.assets.length > 0) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
103
|
+
const validation = FileValidator.validateAssets(mappedResult.assets, {
|
|
104
|
+
maxFileSizeMB: options?.maxFileSizeMB,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!validation.valid) {
|
|
108
|
+
return {
|
|
109
|
+
canceled: true,
|
|
110
|
+
error: validation.error,
|
|
111
|
+
errorMessage: validation.errorMessage,
|
|
112
|
+
};
|
|
152
113
|
}
|
|
153
114
|
}
|
|
154
115
|
|