@umituz/react-native-design-system 4.23.129 → 4.25.0
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/carousel/BannerCarousel.tsx +147 -0
- package/src/carousel/Carousel.tsx +73 -0
- package/src/carousel/CarouselDots.tsx +52 -0
- package/src/carousel/CarouselItem.tsx +35 -0
- package/src/carousel/CarouselScrollView.tsx +56 -0
- package/src/carousel/carouselCalculations.ts +18 -0
- package/src/carousel/index.ts +21 -0
- package/src/carousel/types.ts +31 -0
- package/src/carousel/useCarouselScroll.ts +33 -0
- package/src/gallery/gallery-download.service.ts +69 -0
- package/src/gallery/gallery-save.service.ts +80 -0
- package/src/gallery/index.ts +3 -0
- package/src/gallery/types.ts +11 -0
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.25.0",
|
|
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",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { useAppDesignTokens } from "../theme";
|
|
4
|
+
import { AtomicText } from "../atoms/AtomicText";
|
|
5
|
+
import { AtomicIcon } from "../atoms/icon/AtomicIcon";
|
|
6
|
+
import { Carousel } from "./Carousel";
|
|
7
|
+
import type { CarouselItem as CarouselItemType } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface BannerItem {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
subtitle: string;
|
|
13
|
+
backgroundColor: string;
|
|
14
|
+
action: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BannerCarouselProps {
|
|
18
|
+
items: BannerItem[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const BannerCarousel: React.FC<BannerCarouselProps> = ({ items }) => {
|
|
22
|
+
const tokens = useAppDesignTokens();
|
|
23
|
+
|
|
24
|
+
const renderBannerItem = (
|
|
25
|
+
item: CarouselItemType<BannerItem>,
|
|
26
|
+
_index: number,
|
|
27
|
+
) => {
|
|
28
|
+
const bannerData = item.data;
|
|
29
|
+
const spacing = tokens.spacing;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
style={[
|
|
34
|
+
styles.banner,
|
|
35
|
+
{
|
|
36
|
+
backgroundColor: bannerData.backgroundColor,
|
|
37
|
+
padding: spacing.xl,
|
|
38
|
+
minHeight: 240,
|
|
39
|
+
borderRadius: 28,
|
|
40
|
+
},
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
<View style={styles.bannerContent}>
|
|
44
|
+
<View
|
|
45
|
+
style={[styles.bannerTextContainer, { marginRight: spacing.lg }]}
|
|
46
|
+
>
|
|
47
|
+
<AtomicText
|
|
48
|
+
type="headlineLarge"
|
|
49
|
+
style={[
|
|
50
|
+
styles.bannerTitle,
|
|
51
|
+
{
|
|
52
|
+
color: tokens.colors.textInverse,
|
|
53
|
+
marginBottom: spacing.sm,
|
|
54
|
+
fontSize: 32,
|
|
55
|
+
lineHeight: 40,
|
|
56
|
+
letterSpacing: -0.5,
|
|
57
|
+
},
|
|
58
|
+
]}
|
|
59
|
+
>
|
|
60
|
+
{bannerData.title}
|
|
61
|
+
</AtomicText>
|
|
62
|
+
<AtomicText
|
|
63
|
+
type="bodyLarge"
|
|
64
|
+
style={[
|
|
65
|
+
styles.bannerSubtitle,
|
|
66
|
+
{
|
|
67
|
+
color: tokens.colors.textInverse,
|
|
68
|
+
opacity: 0.95,
|
|
69
|
+
fontSize: 16,
|
|
70
|
+
lineHeight: 24,
|
|
71
|
+
},
|
|
72
|
+
]}
|
|
73
|
+
>
|
|
74
|
+
{bannerData.subtitle}
|
|
75
|
+
</AtomicText>
|
|
76
|
+
</View>
|
|
77
|
+
<View style={styles.bannerIconContainer}>
|
|
78
|
+
<View
|
|
79
|
+
style={[
|
|
80
|
+
styles.bannerIconCircle,
|
|
81
|
+
{
|
|
82
|
+
width: 56,
|
|
83
|
+
height: 56,
|
|
84
|
+
borderRadius: 28,
|
|
85
|
+
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
|
86
|
+
borderWidth: 1,
|
|
87
|
+
borderColor: "rgba(255, 255, 255, 0.4)",
|
|
88
|
+
},
|
|
89
|
+
]}
|
|
90
|
+
>
|
|
91
|
+
<AtomicIcon
|
|
92
|
+
name="arrow-forward-outline"
|
|
93
|
+
size="xl"
|
|
94
|
+
color="onSurface"
|
|
95
|
+
/>
|
|
96
|
+
</View>
|
|
97
|
+
</View>
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const carouselItems: CarouselItemType<BannerItem>[] = items.map((item) => ({
|
|
104
|
+
id: item.id,
|
|
105
|
+
data: item,
|
|
106
|
+
onPress: item.action,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Carousel
|
|
111
|
+
items={carouselItems}
|
|
112
|
+
renderItem={renderBannerItem}
|
|
113
|
+
spacing={16}
|
|
114
|
+
showDots={true}
|
|
115
|
+
pagingEnabled={true}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const styles = StyleSheet.create({
|
|
121
|
+
banner: {
|
|
122
|
+
justifyContent: "center",
|
|
123
|
+
overflow: "hidden",
|
|
124
|
+
},
|
|
125
|
+
bannerContent: {
|
|
126
|
+
flexDirection: "row",
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
justifyContent: "space-between",
|
|
129
|
+
},
|
|
130
|
+
bannerTextContainer: {
|
|
131
|
+
flex: 1,
|
|
132
|
+
},
|
|
133
|
+
bannerTitle: {
|
|
134
|
+
fontWeight: "800",
|
|
135
|
+
},
|
|
136
|
+
bannerSubtitle: {
|
|
137
|
+
fontWeight: "500",
|
|
138
|
+
},
|
|
139
|
+
bannerIconContainer: {
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
justifyContent: "center",
|
|
142
|
+
},
|
|
143
|
+
bannerIconCircle: {
|
|
144
|
+
alignItems: "center",
|
|
145
|
+
justifyContent: "center",
|
|
146
|
+
},
|
|
147
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, ViewStyle } from "react-native";
|
|
3
|
+
import { useAppDesignTokens } from "../theme";
|
|
4
|
+
import { CarouselScrollView } from "./CarouselScrollView";
|
|
5
|
+
import { CarouselDots } from "./CarouselDots";
|
|
6
|
+
import { CarouselItem } from "./CarouselItem";
|
|
7
|
+
import { useCarouselScroll } from "./useCarouselScroll";
|
|
8
|
+
import { calculateItemWidth } from "./carouselCalculations";
|
|
9
|
+
import type { CarouselProps, CarouselItem as CarouselItemType } from "./types";
|
|
10
|
+
|
|
11
|
+
export const Carousel = <T,>({
|
|
12
|
+
items,
|
|
13
|
+
renderItem,
|
|
14
|
+
itemWidth,
|
|
15
|
+
spacing = 16,
|
|
16
|
+
onIndexChange,
|
|
17
|
+
showDots = true,
|
|
18
|
+
pagingEnabled = true,
|
|
19
|
+
style,
|
|
20
|
+
}: CarouselProps<T> & { style?: ViewStyle }) => {
|
|
21
|
+
const tokens = useAppDesignTokens();
|
|
22
|
+
const calculatedItemWidth = itemWidth || calculateItemWidth(spacing);
|
|
23
|
+
|
|
24
|
+
const pageWidth = calculatedItemWidth + spacing;
|
|
25
|
+
|
|
26
|
+
const { currentIndex, handleScroll } = useCarouselScroll({
|
|
27
|
+
itemWidth: pageWidth,
|
|
28
|
+
onIndexChange,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (items.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<View style={[styles.container, style]}>
|
|
37
|
+
<CarouselScrollView
|
|
38
|
+
onScroll={handleScroll}
|
|
39
|
+
pagingEnabled={pagingEnabled}
|
|
40
|
+
spacing={spacing}
|
|
41
|
+
itemWidth={calculatedItemWidth}
|
|
42
|
+
>
|
|
43
|
+
{items.map((item, index) => (
|
|
44
|
+
<CarouselItem
|
|
45
|
+
key={item.id}
|
|
46
|
+
item={item}
|
|
47
|
+
itemWidth={calculatedItemWidth}
|
|
48
|
+
renderContent={(itemData) => renderItem(itemData, index)}
|
|
49
|
+
style={
|
|
50
|
+
index < items.length - 1 ? { marginRight: spacing } : undefined
|
|
51
|
+
}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</CarouselScrollView>
|
|
55
|
+
|
|
56
|
+
{showDots && items.length > 1 && (
|
|
57
|
+
<CarouselDots
|
|
58
|
+
count={items.length}
|
|
59
|
+
currentIndex={currentIndex}
|
|
60
|
+
activeColor={tokens.colors.primary}
|
|
61
|
+
inactiveColor={tokens.colors.border}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const styles = StyleSheet.create({
|
|
69
|
+
container: {
|
|
70
|
+
marginTop: 8,
|
|
71
|
+
marginBottom: 8,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { useAppDesignTokens } from "../theme";
|
|
4
|
+
import type { CarouselDotsProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export const CarouselDots: React.FC<CarouselDotsProps> = ({
|
|
7
|
+
count,
|
|
8
|
+
currentIndex,
|
|
9
|
+
activeColor,
|
|
10
|
+
inactiveColor,
|
|
11
|
+
}) => {
|
|
12
|
+
const tokens = useAppDesignTokens();
|
|
13
|
+
|
|
14
|
+
if (count <= 1) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const activeDotColor = activeColor || tokens.colors.primary;
|
|
19
|
+
const inactiveDotColor = inactiveColor || tokens.colors.border;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<View style={styles.container}>
|
|
23
|
+
{Array.from({ length: count }).map((_, index) => (
|
|
24
|
+
<View
|
|
25
|
+
key={index}
|
|
26
|
+
style={[
|
|
27
|
+
styles.dot,
|
|
28
|
+
{
|
|
29
|
+
backgroundColor:
|
|
30
|
+
index === currentIndex ? activeDotColor : inactiveDotColor,
|
|
31
|
+
},
|
|
32
|
+
]}
|
|
33
|
+
/>
|
|
34
|
+
))}
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const styles = StyleSheet.create({
|
|
40
|
+
container: {
|
|
41
|
+
flexDirection: "row",
|
|
42
|
+
justifyContent: "center",
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
marginTop: 12,
|
|
45
|
+
gap: 8,
|
|
46
|
+
},
|
|
47
|
+
dot: {
|
|
48
|
+
width: 8,
|
|
49
|
+
height: 8,
|
|
50
|
+
borderRadius: 4,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TouchableOpacity, StyleSheet, ViewStyle } from "react-native";
|
|
3
|
+
import type { CarouselItem as CarouselItemType } from "./types";
|
|
4
|
+
|
|
5
|
+
interface CarouselItemProps<T = unknown> {
|
|
6
|
+
item: CarouselItemType<T>;
|
|
7
|
+
itemWidth: number;
|
|
8
|
+
renderContent: (item: CarouselItemType<T>) => React.ReactNode;
|
|
9
|
+
style?: ViewStyle;
|
|
10
|
+
activeOpacity?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const CarouselItem = <T,>({
|
|
14
|
+
item,
|
|
15
|
+
itemWidth,
|
|
16
|
+
renderContent,
|
|
17
|
+
style,
|
|
18
|
+
activeOpacity = 0.9,
|
|
19
|
+
}: CarouselItemProps<T>) => {
|
|
20
|
+
return (
|
|
21
|
+
<TouchableOpacity
|
|
22
|
+
activeOpacity={activeOpacity}
|
|
23
|
+
onPress={item.onPress}
|
|
24
|
+
style={[styles.container, { width: itemWidth }, style]}
|
|
25
|
+
>
|
|
26
|
+
{renderContent(item)}
|
|
27
|
+
</TouchableOpacity>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const styles = StyleSheet.create({
|
|
32
|
+
container: {
|
|
33
|
+
// Container styles handled by width prop
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ScrollView,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
ViewStyle,
|
|
6
|
+
NativeScrollEvent,
|
|
7
|
+
NativeSyntheticEvent,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
|
|
10
|
+
interface CarouselScrollViewProps {
|
|
11
|
+
itemWidth: number;
|
|
12
|
+
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
pagingEnabled?: boolean;
|
|
15
|
+
style?: ViewStyle;
|
|
16
|
+
contentContainerStyle?: ViewStyle;
|
|
17
|
+
spacing?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CarouselScrollView: React.FC<CarouselScrollViewProps> = ({
|
|
21
|
+
onScroll,
|
|
22
|
+
children,
|
|
23
|
+
pagingEnabled = true,
|
|
24
|
+
style,
|
|
25
|
+
contentContainerStyle,
|
|
26
|
+
spacing = 0,
|
|
27
|
+
}) => {
|
|
28
|
+
return (
|
|
29
|
+
<ScrollView
|
|
30
|
+
horizontal
|
|
31
|
+
pagingEnabled={pagingEnabled}
|
|
32
|
+
showsHorizontalScrollIndicator={false}
|
|
33
|
+
onScroll={onScroll}
|
|
34
|
+
scrollEventThrottle={16}
|
|
35
|
+
style={[styles.scrollView, style]}
|
|
36
|
+
contentContainerStyle={[
|
|
37
|
+
styles.scrollContent,
|
|
38
|
+
pagingEnabled
|
|
39
|
+
? { paddingHorizontal: spacing }
|
|
40
|
+
: { paddingLeft: spacing, paddingRight: spacing },
|
|
41
|
+
contentContainerStyle,
|
|
42
|
+
]}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</ScrollView>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create({
|
|
50
|
+
scrollView: {
|
|
51
|
+
marginHorizontal: 0,
|
|
52
|
+
},
|
|
53
|
+
scrollContent: {
|
|
54
|
+
paddingHorizontal: 0,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Dimensions } from "react-native";
|
|
2
|
+
|
|
3
|
+
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
4
|
+
|
|
5
|
+
export const calculateItemWidth = (padding: number = 16): number => {
|
|
6
|
+
return SCREEN_WIDTH - padding * 2;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const calculateIndexFromScroll = (
|
|
10
|
+
scrollPosition: number,
|
|
11
|
+
itemWidth: number,
|
|
12
|
+
): number => {
|
|
13
|
+
return Math.round(scrollPosition / itemWidth);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const getScreenWidth = (): number => {
|
|
17
|
+
return SCREEN_WIDTH;
|
|
18
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CarouselItem,
|
|
3
|
+
CarouselProps,
|
|
4
|
+
CarouselScrollProps,
|
|
5
|
+
CarouselDotsProps,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export { useCarouselScroll } from "./useCarouselScroll";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
calculateItemWidth,
|
|
12
|
+
calculateIndexFromScroll,
|
|
13
|
+
getScreenWidth,
|
|
14
|
+
} from "./carouselCalculations";
|
|
15
|
+
|
|
16
|
+
export { CarouselDots } from "./CarouselDots";
|
|
17
|
+
export { CarouselItem as CarouselItemComponent } from "./CarouselItem";
|
|
18
|
+
export { CarouselScrollView } from "./CarouselScrollView";
|
|
19
|
+
export { Carousel } from "./Carousel";
|
|
20
|
+
export { BannerCarousel } from "./BannerCarousel";
|
|
21
|
+
export type { BannerItem } from "./BannerCarousel";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { NativeSyntheticEvent, NativeScrollEvent } from "react-native";
|
|
2
|
+
|
|
3
|
+
export interface CarouselItem<T = unknown> {
|
|
4
|
+
id: string;
|
|
5
|
+
data: T;
|
|
6
|
+
onPress?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CarouselProps<T = unknown> {
|
|
10
|
+
items: CarouselItem<T>[];
|
|
11
|
+
renderItem: (item: CarouselItem<T>, index: number) => React.ReactNode;
|
|
12
|
+
itemWidth?: number;
|
|
13
|
+
spacing?: number;
|
|
14
|
+
onIndexChange?: (index: number) => void;
|
|
15
|
+
showDots?: boolean;
|
|
16
|
+
pagingEnabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CarouselScrollProps {
|
|
20
|
+
itemWidth: number;
|
|
21
|
+
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
pagingEnabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CarouselDotsProps {
|
|
27
|
+
count: number;
|
|
28
|
+
currentIndex: number;
|
|
29
|
+
activeColor?: string;
|
|
30
|
+
inactiveColor?: string;
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
|
|
3
|
+
import { calculateIndexFromScroll } from "./carouselCalculations";
|
|
4
|
+
|
|
5
|
+
interface UseCarouselScrollOptions {
|
|
6
|
+
itemWidth: number;
|
|
7
|
+
onIndexChange?: (index: number) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const useCarouselScroll = ({
|
|
11
|
+
itemWidth,
|
|
12
|
+
onIndexChange,
|
|
13
|
+
}: UseCarouselScrollOptions) => {
|
|
14
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
15
|
+
|
|
16
|
+
const handleScroll = useCallback(
|
|
17
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
18
|
+
const scrollPosition = event.nativeEvent.contentOffset.x;
|
|
19
|
+
const newIndex = calculateIndexFromScroll(scrollPosition, itemWidth);
|
|
20
|
+
|
|
21
|
+
if (newIndex !== currentIndex) {
|
|
22
|
+
setCurrentIndex(newIndex);
|
|
23
|
+
onIndexChange?.(newIndex);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
[itemWidth, currentIndex, onIndexChange],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
currentIndex,
|
|
31
|
+
handleScroll,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery Download Service
|
|
3
|
+
* Single Responsibility: Download remote media to local storage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { FileSystemService } from "../filesystem";
|
|
7
|
+
import { validateImageUri, getFileExtension } from "../image";
|
|
8
|
+
import { timezoneService } from "../timezone";
|
|
9
|
+
import type { DownloadMediaResult } from "./types";
|
|
10
|
+
|
|
11
|
+
const generateFilename = (uri: string, prefix: string): string => {
|
|
12
|
+
const extension = getFileExtension(uri) || "jpg";
|
|
13
|
+
const timestamp = timezoneService.formatDateToString(new Date());
|
|
14
|
+
const randomId = Math.random().toString(36).substring(2, 10);
|
|
15
|
+
return `${prefix}_${timestamp}_${randomId}.${extension}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class GalleryDownloadService {
|
|
19
|
+
async downloadMedia(
|
|
20
|
+
mediaUri: string,
|
|
21
|
+
prefix: string = "media",
|
|
22
|
+
): Promise<DownloadMediaResult> {
|
|
23
|
+
try {
|
|
24
|
+
const validationResult = validateImageUri(mediaUri, "Media");
|
|
25
|
+
if (!validationResult.isValid) {
|
|
26
|
+
return {
|
|
27
|
+
success: false,
|
|
28
|
+
error: validationResult.error || "Invalid media file",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const filename = generateFilename(mediaUri, prefix);
|
|
33
|
+
const documentDir = FileSystemService.getDocumentDirectory();
|
|
34
|
+
const fileUri = `${documentDir}${filename}`;
|
|
35
|
+
|
|
36
|
+
const downloadResult = await FileSystemService.downloadFile(
|
|
37
|
+
mediaUri,
|
|
38
|
+
fileUri,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!downloadResult.success || !downloadResult.uri) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: downloadResult.error || "Download failed",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
localUri: downloadResult.uri,
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
error: error instanceof Error ? error.message : "Download failed",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRemoteUrl(uri: string): boolean {
|
|
61
|
+
return uri.startsWith("http://") || uri.startsWith("https://");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async cleanupFile(fileUri: string): Promise<void> {
|
|
65
|
+
await FileSystemService.deleteFile(fileUri);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const galleryDownloadService = new GalleryDownloadService();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery Save Service
|
|
3
|
+
* Single Responsibility: Save media to device gallery
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as MediaLibrary from "expo-media-library";
|
|
7
|
+
import { validateImageUri } from "../image";
|
|
8
|
+
import { galleryDownloadService } from "./gallery-download.service";
|
|
9
|
+
import type { SaveMediaResult } from "./types";
|
|
10
|
+
|
|
11
|
+
const requestMediaPermissions = async (): Promise<boolean> => {
|
|
12
|
+
try {
|
|
13
|
+
const { status } = await MediaLibrary.requestPermissionsAsync();
|
|
14
|
+
return status === "granted";
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class GallerySaveService {
|
|
21
|
+
async saveToGallery(
|
|
22
|
+
mediaUri: string,
|
|
23
|
+
prefix?: string,
|
|
24
|
+
): Promise<SaveMediaResult> {
|
|
25
|
+
try {
|
|
26
|
+
const validationResult = validateImageUri(mediaUri, "Media");
|
|
27
|
+
if (!validationResult.isValid) {
|
|
28
|
+
return {
|
|
29
|
+
success: false,
|
|
30
|
+
error: validationResult.error || "Invalid media file",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hasPermission = await requestMediaPermissions();
|
|
35
|
+
if (!hasPermission) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
error: "Media library permission denied",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let localUri = mediaUri;
|
|
43
|
+
const isRemote = galleryDownloadService.isRemoteUrl(mediaUri);
|
|
44
|
+
|
|
45
|
+
if (isRemote) {
|
|
46
|
+
const downloadResult = await galleryDownloadService.downloadMedia(
|
|
47
|
+
mediaUri,
|
|
48
|
+
prefix,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (!downloadResult.success || !downloadResult.localUri) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
error: downloadResult.error || "Download failed",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
localUri = downloadResult.localUri;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const asset = await MediaLibrary.createAssetAsync(localUri);
|
|
62
|
+
|
|
63
|
+
if (isRemote && localUri) {
|
|
64
|
+
await galleryDownloadService.cleanupFile(localUri);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
fileUri: asset.uri,
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: error instanceof Error ? error.message : "Save failed",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const gallerySaveService = new GallerySaveService();
|
package/src/index.ts
CHANGED
|
@@ -147,3 +147,13 @@ export * from "./loading";
|
|
|
147
147
|
// INIT EXPORTS
|
|
148
148
|
// =============================================================================
|
|
149
149
|
export * from "./init";
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// GALLERY EXPORTS
|
|
153
|
+
// =============================================================================
|
|
154
|
+
export * from "./gallery";
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// CAROUSEL EXPORTS
|
|
158
|
+
// =============================================================================
|
|
159
|
+
export * from "./carousel";
|