@umituz/react-native-photo-editor 1.1.2 → 2.0.2

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 CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@umituz/react-native-photo-editor",
3
- "version": "1.1.2",
3
+ "version": "2.0.2",
4
4
  "description": "A powerful, generic photo editor for React Native",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
7
10
  "publishConfig": {
8
11
  "access": "public"
9
12
  },
@@ -14,33 +17,36 @@
14
17
  "keywords": [
15
18
  "react-native",
16
19
  "photo-editor",
17
- "image-editor"
20
+ "image-editor",
21
+ "layers",
22
+ "stickers",
23
+ "filters"
18
24
  ],
19
25
  "author": "UmitUZ",
20
26
  "license": "MIT",
21
27
  "peerDependencies": {
22
- "react": "*",
23
- "react-native": "*",
24
28
  "@umituz/react-native-design-system": "*",
25
- "react-native-gesture-handler": "*",
26
- "react-native-safe-area-context": "*"
29
+ "expo-image": ">=1.0.0",
30
+ "react": ">=18.0.0",
31
+ "react-native": ">=0.73.0",
32
+ "react-native-gesture-handler": ">=2.0.0",
33
+ "react-native-safe-area-context": ">=4.0.0"
27
34
  },
28
35
  "dependencies": {
29
- "expo-image-picker": "*",
30
- "expo-media-library": "*",
31
36
  "expo-file-system": "*",
32
- "expo-image": "*"
37
+ "expo-image-picker": "*",
38
+ "expo-media-library": "*"
33
39
  },
34
40
  "devDependencies": {
35
41
  "@types/react": "*",
36
42
  "@types/react-native": "*",
37
- "@typescript-eslint/parser": "*",
38
43
  "@typescript-eslint/eslint-plugin": "*",
44
+ "@typescript-eslint/parser": "*",
45
+ "@umituz/react-native-design-system": "^4.25.90",
39
46
  "eslint": "*",
40
- "typescript": "*",
47
+ "expo-image": "*",
41
48
  "react-native-gesture-handler": "*",
42
49
  "react-native-safe-area-context": "*",
43
- "expo-application": "*",
44
- "expo-device": "*"
50
+ "typescript": "*"
45
51
  }
46
52
  }
@@ -1,14 +1,9 @@
1
1
  import React, { useMemo } from "react";
2
2
  import { View, ScrollView, TouchableOpacity } from "react-native";
3
- import {
4
- useAppDesignTokens,
5
- AtomicText,
6
- AtomicIcon,
7
- BottomSheetModal,
8
- SafeBottomSheetModalProvider,
9
- useSafeAreaInsets,
10
- DesignTokens,
11
- } from "@umituz/react-native-design-system";
3
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
4
+ import { BottomSheetModal } from "@umituz/react-native-design-system/molecules";
5
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
6
+ import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
12
7
 
13
8
  import { EditorCanvas } from "./components/EditorCanvas";
14
9
  import { EditorToolbar } from "./components/EditorToolbar";
@@ -17,29 +12,43 @@ import { LayerManager } from "./components/LayerManager";
17
12
  import { TextEditorSheet } from "./components/TextEditorSheet";
18
13
  import { StickerPicker } from "./components/StickerPicker";
19
14
  import { FilterPicker } from "./components/FilterPicker";
15
+ import { AdjustmentsSheet } from "./components/AdjustmentsSheet";
20
16
  import { AIMagicSheet } from "./components/AIMagicSheet";
21
17
  import { createEditorStyles } from "./styles";
22
18
  import { usePhotoEditorUI } from "./hooks/usePhotoEditorUI";
23
- import { Layer } from "./types";
19
+ import { Layer, ImageFilters } from "./types";
24
20
  import { DEFAULT_FONTS } from "./constants";
25
21
 
26
22
  export interface EditorActions {
27
- addTextLayer: (tokens: DesignTokens) => string;
23
+ addTextLayer: (defaultColor?: string) => string;
28
24
  updateLayer: (id: string, updates: Partial<Layer>) => void;
25
+ duplicateLayer: (id: string) => string | null;
26
+ deleteLayer: (id: string) => void;
27
+ moveLayerUp: (id: string) => void;
28
+ moveLayerDown: (id: string) => void;
29
29
  getLayers: () => Layer[];
30
30
  getActiveLayerId: () => string | null;
31
+ undo: () => void;
32
+ redo: () => void;
31
33
  }
32
34
 
33
35
  export interface PhotoEditorProps {
34
36
  imageUri: string;
35
- onSave?: (uri: string) => void;
37
+ /**
38
+ * Called when the user taps Save.
39
+ * Receives the original imageUri, current layers, and active filters
40
+ * so the host app can composite/export however it needs.
41
+ */
42
+ onSave?: (uri: string, layers: Layer[], filters: ImageFilters) => void;
36
43
  onClose: () => void;
37
44
  title?: string;
45
+ /** Render extra tools below the canvas. Receives editor action helpers. */
38
46
  customTools?: React.ReactNode | ((actions: EditorActions) => React.ReactNode);
39
47
  initialCaption?: string;
40
48
  t: (key: string) => string;
41
49
  fonts?: readonly string[];
42
- showAI?: boolean;
50
+ /** Pass a handler to enable the AI caption feature */
51
+ onAICaption?: (style: string) => Promise<string> | void;
43
52
  }
44
53
 
45
54
  export const PhotoEditor: React.FC<PhotoEditorProps> = ({
@@ -51,43 +60,152 @@ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
51
60
  initialCaption,
52
61
  t,
53
62
  fonts = DEFAULT_FONTS,
54
- showAI = true,
63
+ onAICaption,
55
64
  }) => {
56
65
  const tokens = useAppDesignTokens();
57
66
  const insets = useSafeAreaInsets();
58
- const styles = useMemo(() => createEditorStyles(tokens, insets), [tokens, insets]);
59
- const ui = usePhotoEditorUI(initialCaption, tokens);
67
+ const styles = useMemo(
68
+ () => createEditorStyles(tokens, insets),
69
+ [tokens, insets],
70
+ );
71
+ const ui = usePhotoEditorUI(initialCaption);
72
+
73
+ const actions: EditorActions = useMemo(
74
+ () => ({
75
+ addTextLayer: (color?: string) =>
76
+ ui.addTextLayer(color ?? tokens.colors.textPrimary),
77
+ updateLayer: ui.updateLayer,
78
+ duplicateLayer: ui.duplicateLayer,
79
+ deleteLayer: ui.deleteLayer,
80
+ moveLayerUp: ui.moveLayerUp,
81
+ moveLayerDown: ui.moveLayerDown,
82
+ getLayers: () => ui.layers,
83
+ getActiveLayerId: () => ui.activeLayerId,
84
+ undo: ui.undo,
85
+ redo: ui.redo,
86
+ }),
87
+ [ui, tokens.colors.textPrimary],
88
+ );
60
89
 
61
- const actions: EditorActions = useMemo(() => ({
62
- addTextLayer: ui.addTextLayer,
63
- updateLayer: ui.updateLayer,
64
- getLayers: () => ui.layers,
65
- getActiveLayerId: () => ui.activeLayerId,
66
- }), [ui.addTextLayer, ui.updateLayer, ui.layers, ui.activeLayerId]);
90
+ const handleSave = () => onSave?.(imageUri, ui.layers, ui.filters);
67
91
 
68
92
  return (
69
- <SafeBottomSheetModalProvider>
70
- <View style={styles.container}>
71
- <View style={styles.header}>
72
- <TouchableOpacity onPress={onClose}><AtomicIcon name="close" size="md" color="textPrimary" /></TouchableOpacity>
73
- <AtomicText type="headlineSmall" style={styles.headerTitle}>{title}</AtomicText>
74
- <TouchableOpacity onPress={() => onSave?.(imageUri)}><AtomicText fontWeight="bold" color="primary">{t("common.save")}</AtomicText></TouchableOpacity>
75
- </View>
76
-
77
- <ScrollView contentContainerStyle={styles.scrollContent}>
78
- <EditorCanvas imageUrl={imageUri} layers={ui.layers} activeLayerId={ui.activeLayerId} onLayerTap={ui.handleTextLayerTap} onLayerMove={(id, x, y) => ui.updateLayer(id, { x, y })} styles={styles} />
79
- {typeof customTools === "function" ? customTools(actions) : customTools}
80
- <FontControls fontSize={ui.fontSize} selectedFont={ui.selectedFont} fonts={fonts} onFontSizeChange={ui.setFontSize} onFontSelect={ui.setSelectedFont} styles={styles} />
81
- </ScrollView>
82
-
83
- <EditorToolbar onAddText={ui.handleAddText} onAddSticker={() => ui.stickerSheetRef.current?.present()} onOpenFilters={() => ui.filterSheetRef.current?.present()} onOpenLayers={() => ui.layerSheetRef.current?.present()} onAIMagic={showAI ? () => ui.aiSheetRef.current?.present() : undefined} styles={styles} t={t} />
84
-
85
- <BottomSheetModal ref={ui.textEditorSheetRef} snapPoints={["40%"]}><TextEditorSheet value={ui.editingText} onChange={ui.setEditingText} onSave={ui.handleSaveText} t={t} /></BottomSheetModal>
86
- <BottomSheetModal ref={ui.stickerSheetRef} snapPoints={["50%"]}><StickerPicker onSelectSticker={ui.handleSelectSticker} /></BottomSheetModal>
87
- <BottomSheetModal ref={ui.filterSheetRef} snapPoints={["40%"]}><FilterPicker selectedFilter={ui.selectedFilter} onSelectFilter={ui.handleSelectFilter} /></BottomSheetModal>
88
- <BottomSheetModal ref={ui.layerSheetRef} snapPoints={["50%"]}><LayerManager layers={ui.layers} activeLayerId={ui.activeLayerId} onSelectLayer={ui.selectLayer} onDeleteLayer={ui.deleteLayer} t={t} /></BottomSheetModal>
89
- <BottomSheetModal ref={ui.aiSheetRef} snapPoints={["60%"]}><AIMagicSheet onGenerateCaption={(_s) => { ui.aiSheetRef.current?.dismiss(); /* AI trigger */ }} /></BottomSheetModal>
93
+ <View style={styles.container}>
94
+ {/* Header */}
95
+ <View style={styles.header}>
96
+ <TouchableOpacity
97
+ onPress={onClose}
98
+ accessibilityLabel="Close editor"
99
+ accessibilityRole="button"
100
+ >
101
+ <AtomicIcon name="close" size="md" color="textPrimary" />
102
+ </TouchableOpacity>
103
+ <AtomicText type="headlineSmall" style={styles.headerTitle}>
104
+ {title}
105
+ </AtomicText>
106
+ <TouchableOpacity
107
+ onPress={handleSave}
108
+ accessibilityLabel="Save"
109
+ accessibilityRole="button"
110
+ >
111
+ <AtomicText fontWeight="bold" color="primary">
112
+ {t("common.save") || "Save"}
113
+ </AtomicText>
114
+ </TouchableOpacity>
90
115
  </View>
91
- </SafeBottomSheetModalProvider>
116
+
117
+ <ScrollView contentContainerStyle={styles.scrollContent}>
118
+ <EditorCanvas
119
+ imageUrl={imageUri}
120
+ layers={ui.layers}
121
+ activeLayerId={ui.activeLayerId}
122
+ filters={ui.filters}
123
+ onLayerTap={ui.handleTextLayerTap}
124
+ onLayerTransform={ui.handleLayerTransform}
125
+ styles={styles}
126
+ />
127
+
128
+ {typeof customTools === "function" ? customTools(actions) : customTools}
129
+
130
+ <FontControls
131
+ fontSize={ui.fontSize}
132
+ selectedFont={ui.selectedFont}
133
+ fonts={fonts}
134
+ onFontSizeChange={ui.setFontSize}
135
+ onFontSelect={ui.setSelectedFont}
136
+ styles={styles}
137
+ />
138
+ </ScrollView>
139
+
140
+ <EditorToolbar
141
+ onAddText={ui.handleAddText}
142
+ onAddSticker={() => ui.stickerSheetRef.current?.present()}
143
+ onOpenFilters={() => ui.filterSheetRef.current?.present()}
144
+ onOpenAdjustments={() => ui.adjustmentsSheetRef.current?.present()}
145
+ onOpenLayers={() => ui.layerSheetRef.current?.present()}
146
+ onAIMagic={onAICaption ? () => ui.aiSheetRef.current?.present() : undefined}
147
+ onUndo={ui.undo}
148
+ onRedo={ui.redo}
149
+ canUndo={ui.canUndo}
150
+ canRedo={ui.canRedo}
151
+ styles={styles}
152
+ t={t}
153
+ />
154
+
155
+ {/* Bottom Sheets */}
156
+ <BottomSheetModal ref={ui.textEditorSheetRef} snapPoints={["55%"]}>
157
+ <TextEditorSheet
158
+ value={ui.editingText}
159
+ onChange={ui.setEditingText}
160
+ onSave={ui.handleSaveText}
161
+ t={t}
162
+ color={ui.editingColor}
163
+ onColorChange={ui.setEditingColor}
164
+ textAlign={ui.editingAlign}
165
+ onTextAlignChange={ui.setEditingAlign}
166
+ isBold={ui.editingBold}
167
+ onBoldChange={ui.setEditingBold}
168
+ isItalic={ui.editingItalic}
169
+ onItalicChange={ui.setEditingItalic}
170
+ />
171
+ </BottomSheetModal>
172
+
173
+ <BottomSheetModal ref={ui.stickerSheetRef} snapPoints={["50%"]}>
174
+ <StickerPicker onSelectSticker={ui.handleSelectSticker} />
175
+ </BottomSheetModal>
176
+
177
+ <BottomSheetModal ref={ui.filterSheetRef} snapPoints={["40%"]}>
178
+ <FilterPicker
179
+ selectedFilter={ui.selectedFilter}
180
+ onSelectFilter={ui.handleSelectFilter}
181
+ />
182
+ </BottomSheetModal>
183
+
184
+ <BottomSheetModal ref={ui.adjustmentsSheetRef} snapPoints={["55%"]}>
185
+ <AdjustmentsSheet
186
+ filters={ui.filters}
187
+ onFiltersChange={ui.updateFilters}
188
+ />
189
+ </BottomSheetModal>
190
+
191
+ <BottomSheetModal ref={ui.layerSheetRef} snapPoints={["55%"]}>
192
+ <LayerManager
193
+ layers={ui.layers}
194
+ activeLayerId={ui.activeLayerId}
195
+ onSelectLayer={ui.selectLayer}
196
+ onDeleteLayer={ui.deleteLayer}
197
+ onDuplicateLayer={ui.duplicateLayer}
198
+ onMoveLayerUp={ui.moveLayerUp}
199
+ onMoveLayerDown={ui.moveLayerDown}
200
+ t={t}
201
+ />
202
+ </BottomSheetModal>
203
+
204
+ {onAICaption && (
205
+ <BottomSheetModal ref={ui.aiSheetRef} snapPoints={["60%"]}>
206
+ <AIMagicSheet onGenerateCaption={onAICaption} />
207
+ </BottomSheetModal>
208
+ )}
209
+ </View>
92
210
  );
93
211
  };
@@ -1,29 +1,27 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, useMemo } from "react";
2
2
  import { View, ScrollView, TouchableOpacity, StyleSheet } from "react-native";
3
- import { AtomicText, AtomicIcon, useAppDesignTokens, AtomicButton } from "@umituz/react-native-design-system";
3
+ import { AtomicText, AtomicIcon, AtomicButton } from "@umituz/react-native-design-system/atoms";
4
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
5
+ import { DEFAULT_AI_STYLES } from "../constants";
4
6
 
5
7
  interface AIMagicSheetProps {
6
- onGenerateCaption: (style: string) => void;
8
+ /**
9
+ * Called with the selected style ID. Should return a generated caption string.
10
+ * If undefined, the AI button is disabled.
11
+ */
12
+ onGenerateCaption?: (style: string) => Promise<string> | void;
7
13
  isLoading?: boolean;
8
14
  }
9
15
 
10
- const AI_STYLES = [
11
- { id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
12
- { id: "funny", label: "😂 Funny", desc: "Humor that connects" },
13
- { id: "savage", label: "🔥 Savage", desc: "Bold & edgy" },
14
- { id: "wholesome", label: "💕 Wholesome", desc: "Warm & positive" },
15
- { id: "sarcastic", label: "😏 Sarcastic", desc: "Witty & ironic" },
16
- { id: "relatable", label: "🎯 Relatable", desc: "Everyone gets it" },
17
- ];
18
-
19
16
  export const AIMagicSheet: React.FC<AIMagicSheetProps> = ({
20
17
  onGenerateCaption,
21
18
  isLoading = false,
22
19
  }) => {
23
20
  const tokens = useAppDesignTokens();
24
21
  const [selected, setSelected] = useState<string | null>(null);
22
+ const [loading, setLoading] = useState(false);
25
23
 
26
- const styles = StyleSheet.create({
24
+ const styles = useMemo(() => StyleSheet.create({
27
25
  container: { padding: tokens.spacing.md, gap: tokens.spacing.md },
28
26
  header: { flexDirection: "row", alignItems: "center", gap: tokens.spacing.sm },
29
27
  grid: { gap: tokens.spacing.sm },
@@ -41,7 +39,19 @@ export const AIMagicSheet: React.FC<AIMagicSheetProps> = ({
41
39
  backgroundColor: tokens.colors.primary + "10",
42
40
  },
43
41
  info: { flex: 1, marginLeft: tokens.spacing.sm },
44
- });
42
+ }), [tokens]);
43
+
44
+ const handleGenerate = async () => {
45
+ if (!selected || !onGenerateCaption) return;
46
+ setLoading(true);
47
+ try {
48
+ await onGenerateCaption(selected);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ const isGenerating = isLoading || loading;
45
55
 
46
56
  return (
47
57
  <View style={styles.container}>
@@ -51,29 +61,43 @@ export const AIMagicSheet: React.FC<AIMagicSheetProps> = ({
51
61
  </View>
52
62
  <ScrollView showsVerticalScrollIndicator={false}>
53
63
  <View style={styles.grid}>
54
- {AI_STYLES.map((style) => (
55
- <TouchableOpacity
56
- key={style.id}
57
- style={[styles.card, selected === style.id && styles.cardActive]}
58
- onPress={() => setSelected(style.id)}
59
- >
60
- <AtomicText style={{ fontSize: 24 }}>{style.label.split(" ")[0]}</AtomicText>
61
- <View style={styles.info}>
62
- <AtomicText fontWeight="bold" color={selected === style.id ? "primary" : "textPrimary"}>
63
- {style.label.split(" ").slice(1).join(" ")}
64
- </AtomicText>
65
- <AtomicText type="labelSmall" color="textSecondary">{style.desc}</AtomicText>
66
- </View>
67
- {selected === style.id && <AtomicIcon name="checkmark-circle" size="md" color="primary" />}
68
- </TouchableOpacity>
69
- ))}
64
+ {DEFAULT_AI_STYLES.map((style) => {
65
+ const isActive = selected === style.id;
66
+ const [emoji, ...words] = style.label.split(" ");
67
+ return (
68
+ <TouchableOpacity
69
+ key={style.id}
70
+ style={[styles.card, isActive && styles.cardActive]}
71
+ onPress={() => setSelected(style.id)}
72
+ accessibilityLabel={style.label}
73
+ accessibilityRole="button"
74
+ accessibilityState={{ selected: isActive }}
75
+ >
76
+ <AtomicText style={{ fontSize: 24 }}>{emoji}</AtomicText>
77
+ <View style={styles.info}>
78
+ <AtomicText
79
+ fontWeight="bold"
80
+ color={isActive ? "primary" : "textPrimary"}
81
+ >
82
+ {words.join(" ")}
83
+ </AtomicText>
84
+ <AtomicText type="labelSmall" color="textSecondary">
85
+ {style.desc}
86
+ </AtomicText>
87
+ </View>
88
+ {isActive && (
89
+ <AtomicIcon name="checkmark-circle" size="md" color="primary" />
90
+ )}
91
+ </TouchableOpacity>
92
+ );
93
+ })}
70
94
  </View>
71
95
  </ScrollView>
72
96
  <AtomicButton
73
97
  variant="primary"
74
- disabled={!selected || isLoading}
75
- onPress={() => selected && onGenerateCaption(selected)}
76
- loading={isLoading}
98
+ disabled={!selected || !onGenerateCaption || isGenerating}
99
+ onPress={handleGenerate}
100
+ loading={isGenerating}
77
101
  icon="sparkles"
78
102
  >
79
103
  Generate Caption
@@ -0,0 +1,108 @@
1
+ import React from "react";
2
+ import { View, TouchableOpacity } from "react-native";
3
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
4
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
5
+ import { Slider } from "./Slider";
6
+ import { ImageFilters, DEFAULT_IMAGE_FILTERS } from "../types";
7
+
8
+ interface AdjustmentsSheetProps {
9
+ filters: ImageFilters;
10
+ onFiltersChange: (filters: ImageFilters) => void;
11
+ }
12
+
13
+ export const AdjustmentsSheet: React.FC<AdjustmentsSheetProps> = ({
14
+ filters,
15
+ onFiltersChange,
16
+ }) => {
17
+ const tokens = useAppDesignTokens();
18
+
19
+ const update = (key: keyof ImageFilters, val: number) => {
20
+ onFiltersChange({ ...filters, [key]: val });
21
+ };
22
+
23
+ const handleReset = () => onFiltersChange(DEFAULT_IMAGE_FILTERS);
24
+
25
+ return (
26
+ <View style={{ padding: tokens.spacing.md, gap: tokens.spacing.lg }}>
27
+ <View
28
+ style={{
29
+ flexDirection: "row",
30
+ alignItems: "center",
31
+ justifyContent: "space-between",
32
+ }}
33
+ >
34
+ <View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.sm }}>
35
+ <AtomicIcon name="brush" size="md" color="primary" />
36
+ <AtomicText type="headlineSmall">Adjustments</AtomicText>
37
+ </View>
38
+ <TouchableOpacity
39
+ onPress={handleReset}
40
+ accessibilityLabel="Reset adjustments"
41
+ accessibilityRole="button"
42
+ style={{
43
+ paddingHorizontal: tokens.spacing.md,
44
+ paddingVertical: tokens.spacing.xs,
45
+ backgroundColor: tokens.colors.surfaceVariant,
46
+ borderRadius: tokens.borders.radius.sm,
47
+ }}
48
+ >
49
+ <AtomicText type="labelSmall" color="textSecondary">
50
+ Reset
51
+ </AtomicText>
52
+ </TouchableOpacity>
53
+ </View>
54
+
55
+ <Slider
56
+ label="Brightness"
57
+ value={filters.brightness}
58
+ min={0.5}
59
+ max={2}
60
+ step={0.05}
61
+ onValueChange={(v) => update("brightness", v)}
62
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
63
+ />
64
+
65
+ <Slider
66
+ label="Contrast"
67
+ value={filters.contrast}
68
+ min={0.5}
69
+ max={2}
70
+ step={0.05}
71
+ onValueChange={(v) => update("contrast", v)}
72
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
73
+ />
74
+
75
+ <Slider
76
+ label="Saturation"
77
+ value={filters.saturation}
78
+ min={0}
79
+ max={2}
80
+ step={0.05}
81
+ onValueChange={(v) => update("saturation", v)}
82
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
83
+ />
84
+
85
+ <Slider
86
+ label="Hue Rotate"
87
+ value={filters.hueRotate ?? 0}
88
+ min={0}
89
+ max={360}
90
+ step={1}
91
+ onValueChange={(v) => update("hueRotate", v)}
92
+ formatValue={(v) => `${Math.round(v)}°`}
93
+ />
94
+
95
+ <Slider
96
+ label="Sepia"
97
+ value={filters.sepia}
98
+ min={0}
99
+ max={1}
100
+ step={0.05}
101
+ onValueChange={(v) => update("sepia", v)}
102
+ formatValue={(v) => `${Math.round(v * 100)}%`}
103
+ />
104
+ </View>
105
+ );
106
+ };
107
+
108
+ export default React.memo(AdjustmentsSheet);
@@ -0,0 +1,77 @@
1
+ import React from "react";
2
+ import { View, TouchableOpacity } from "react-native";
3
+ import { AtomicText } from "@umituz/react-native-design-system/atoms";
4
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
5
+ import { DEFAULT_TEXT_COLORS } from "../constants";
6
+
7
+ interface ColorPickerProps {
8
+ selectedColor: string;
9
+ onSelectColor: (color: string) => void;
10
+ label?: string;
11
+ colors?: readonly string[];
12
+ }
13
+
14
+ export const ColorPicker: React.FC<ColorPickerProps> = ({
15
+ selectedColor,
16
+ onSelectColor,
17
+ label,
18
+ colors = DEFAULT_TEXT_COLORS,
19
+ }) => {
20
+ const tokens = useAppDesignTokens();
21
+
22
+ return (
23
+ <View style={{ gap: tokens.spacing.xs }}>
24
+ {label && (
25
+ <AtomicText type="labelMedium" color="textSecondary">
26
+ {label}
27
+ </AtomicText>
28
+ )}
29
+ <View
30
+ style={{
31
+ flexDirection: "row",
32
+ flexWrap: "wrap",
33
+ gap: tokens.spacing.xs,
34
+ }}
35
+ >
36
+ {colors.map((color) => {
37
+ const isSelected = selectedColor === color;
38
+ return (
39
+ <TouchableOpacity
40
+ key={color}
41
+ onPress={() => onSelectColor(color)}
42
+ accessibilityLabel={`Color ${color}`}
43
+ accessibilityRole="button"
44
+ accessibilityState={{ selected: isSelected }}
45
+ style={{
46
+ width: 34,
47
+ height: 34,
48
+ borderRadius: 17,
49
+ backgroundColor: color,
50
+ borderWidth: isSelected ? 3 : 1.5,
51
+ borderColor: isSelected
52
+ ? tokens.colors.primary
53
+ : tokens.colors.border,
54
+ alignItems: "center",
55
+ justifyContent: "center",
56
+ }}
57
+ >
58
+ {isSelected && (
59
+ <View
60
+ style={{
61
+ width: 10,
62
+ height: 10,
63
+ borderRadius: 5,
64
+ backgroundColor:
65
+ color === "#FFFFFF" ? "#000000" : "#FFFFFF",
66
+ }}
67
+ />
68
+ )}
69
+ </TouchableOpacity>
70
+ );
71
+ })}
72
+ </View>
73
+ </View>
74
+ );
75
+ };
76
+
77
+ export default React.memo(ColorPicker);