@harkenapp/sdk-react-native 0.0.1-alpha.1

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.
Files changed (235) hide show
  1. package/README.md +67 -0
  2. package/app.plugin.cjs +135 -0
  3. package/app.plugin.js +1 -0
  4. package/dist/api/client.d.ts +67 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +163 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +46 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +72 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/api/index.d.ts +7 -0
  13. package/dist/api/index.d.ts.map +1 -0
  14. package/dist/api/index.js +20 -0
  15. package/dist/api/index.js.map +1 -0
  16. package/dist/api/retry.d.ts +29 -0
  17. package/dist/api/retry.d.ts.map +1 -0
  18. package/dist/api/retry.js +74 -0
  19. package/dist/api/retry.js.map +1 -0
  20. package/dist/attachments/FeedbackSheet.d.ts +88 -0
  21. package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
  22. package/dist/attachments/FeedbackSheet.js +250 -0
  23. package/dist/attachments/FeedbackSheet.js.map +1 -0
  24. package/dist/attachments/index.d.ts +20 -0
  25. package/dist/attachments/index.d.ts.map +1 -0
  26. package/dist/attachments/index.js +40 -0
  27. package/dist/attachments/index.js.map +1 -0
  28. package/dist/components/AttachmentGrid.d.ts +94 -0
  29. package/dist/components/AttachmentGrid.d.ts.map +1 -0
  30. package/dist/components/AttachmentGrid.js +132 -0
  31. package/dist/components/AttachmentGrid.js.map +1 -0
  32. package/dist/components/AttachmentPicker.d.ts +98 -0
  33. package/dist/components/AttachmentPicker.d.ts.map +1 -0
  34. package/dist/components/AttachmentPicker.js +297 -0
  35. package/dist/components/AttachmentPicker.js.map +1 -0
  36. package/dist/components/AttachmentPreview.d.ts +78 -0
  37. package/dist/components/AttachmentPreview.d.ts.map +1 -0
  38. package/dist/components/AttachmentPreview.js +133 -0
  39. package/dist/components/AttachmentPreview.js.map +1 -0
  40. package/dist/components/CategorySelector.d.ts +77 -0
  41. package/dist/components/CategorySelector.d.ts.map +1 -0
  42. package/dist/components/CategorySelector.js +117 -0
  43. package/dist/components/CategorySelector.js.map +1 -0
  44. package/dist/components/FeedbackForm.d.ts +50 -0
  45. package/dist/components/FeedbackForm.d.ts.map +1 -0
  46. package/dist/components/FeedbackForm.js +141 -0
  47. package/dist/components/FeedbackForm.js.map +1 -0
  48. package/dist/components/FeedbackSheet.d.ts +75 -0
  49. package/dist/components/FeedbackSheet.d.ts.map +1 -0
  50. package/dist/components/FeedbackSheet.js +215 -0
  51. package/dist/components/FeedbackSheet.js.map +1 -0
  52. package/dist/components/ThemedButton.d.ts +23 -0
  53. package/dist/components/ThemedButton.d.ts.map +1 -0
  54. package/dist/components/ThemedButton.js +77 -0
  55. package/dist/components/ThemedButton.js.map +1 -0
  56. package/dist/components/ThemedText.d.ts +16 -0
  57. package/dist/components/ThemedText.d.ts.map +1 -0
  58. package/dist/components/ThemedText.js +44 -0
  59. package/dist/components/ThemedText.js.map +1 -0
  60. package/dist/components/ThemedTextInput.d.ts +13 -0
  61. package/dist/components/ThemedTextInput.d.ts.map +1 -0
  62. package/dist/components/ThemedTextInput.js +76 -0
  63. package/dist/components/ThemedTextInput.js.map +1 -0
  64. package/dist/components/UploadStatusOverlay.d.ts +82 -0
  65. package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
  66. package/dist/components/UploadStatusOverlay.js +319 -0
  67. package/dist/components/UploadStatusOverlay.js.map +1 -0
  68. package/dist/components/index.d.ts +19 -0
  69. package/dist/components/index.d.ts.map +1 -0
  70. package/dist/components/index.js +28 -0
  71. package/dist/components/index.js.map +1 -0
  72. package/dist/context/HarkenContext.d.ts +62 -0
  73. package/dist/context/HarkenContext.d.ts.map +1 -0
  74. package/dist/context/HarkenContext.js +128 -0
  75. package/dist/context/HarkenContext.js.map +1 -0
  76. package/dist/context/index.d.ts +3 -0
  77. package/dist/context/index.d.ts.map +1 -0
  78. package/dist/context/index.js +7 -0
  79. package/dist/context/index.js.map +1 -0
  80. package/dist/domain/index.d.ts +3 -0
  81. package/dist/domain/index.d.ts.map +1 -0
  82. package/dist/domain/index.js +7 -0
  83. package/dist/domain/index.js.map +1 -0
  84. package/dist/domain/upload-queue.d.ts +116 -0
  85. package/dist/domain/upload-queue.d.ts.map +1 -0
  86. package/dist/domain/upload-queue.js +34 -0
  87. package/dist/domain/upload-queue.js.map +1 -0
  88. package/dist/hooks/index.d.ts +6 -0
  89. package/dist/hooks/index.d.ts.map +1 -0
  90. package/dist/hooks/index.js +16 -0
  91. package/dist/hooks/index.js.map +1 -0
  92. package/dist/hooks/useAnonymousId.d.ts +28 -0
  93. package/dist/hooks/useAnonymousId.d.ts.map +1 -0
  94. package/dist/hooks/useAnonymousId.js +59 -0
  95. package/dist/hooks/useAnonymousId.js.map +1 -0
  96. package/dist/hooks/useAttachmentPicker.d.ts +84 -0
  97. package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
  98. package/dist/hooks/useAttachmentPicker.js +181 -0
  99. package/dist/hooks/useAttachmentPicker.js.map +1 -0
  100. package/dist/hooks/useAttachmentStatus.d.ts +51 -0
  101. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
  102. package/dist/hooks/useAttachmentStatus.js +69 -0
  103. package/dist/hooks/useAttachmentStatus.js.map +1 -0
  104. package/dist/hooks/useAttachmentUpload.d.ts +101 -0
  105. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
  106. package/dist/hooks/useAttachmentUpload.js +293 -0
  107. package/dist/hooks/useAttachmentUpload.js.map +1 -0
  108. package/dist/hooks/useFeedback.d.ts +55 -0
  109. package/dist/hooks/useFeedback.d.ts.map +1 -0
  110. package/dist/hooks/useFeedback.js +96 -0
  111. package/dist/hooks/useFeedback.js.map +1 -0
  112. package/dist/hooks/useHarkenContext.d.ts +25 -0
  113. package/dist/hooks/useHarkenContext.d.ts.map +1 -0
  114. package/dist/hooks/useHarkenContext.js +35 -0
  115. package/dist/hooks/useHarkenContext.js.map +1 -0
  116. package/dist/hooks/useHarkenTheme.d.ts +26 -0
  117. package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
  118. package/dist/hooks/useHarkenTheme.js +36 -0
  119. package/dist/hooks/useHarkenTheme.js.map +1 -0
  120. package/dist/index.d.ts +49 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +91 -0
  123. package/dist/index.js.map +1 -0
  124. package/dist/services/index.d.ts +4 -0
  125. package/dist/services/index.d.ts.map +1 -0
  126. package/dist/services/index.js +9 -0
  127. package/dist/services/index.js.map +1 -0
  128. package/dist/services/uploadQueueService.d.ts +193 -0
  129. package/dist/services/uploadQueueService.d.ts.map +1 -0
  130. package/dist/services/uploadQueueService.js +623 -0
  131. package/dist/services/uploadQueueService.js.map +1 -0
  132. package/dist/services/uploadQueueStorage.d.ts +30 -0
  133. package/dist/services/uploadQueueStorage.d.ts.map +1 -0
  134. package/dist/services/uploadQueueStorage.js +77 -0
  135. package/dist/services/uploadQueueStorage.js.map +1 -0
  136. package/dist/storage/IdentityStore.d.ts +38 -0
  137. package/dist/storage/IdentityStore.d.ts.map +1 -0
  138. package/dist/storage/IdentityStore.js +83 -0
  139. package/dist/storage/IdentityStore.js.map +1 -0
  140. package/dist/storage/SecureStoreAdapter.d.ts +28 -0
  141. package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
  142. package/dist/storage/SecureStoreAdapter.js +52 -0
  143. package/dist/storage/SecureStoreAdapter.js.map +1 -0
  144. package/dist/storage/defaultStorage.d.ts +20 -0
  145. package/dist/storage/defaultStorage.d.ts.map +1 -0
  146. package/dist/storage/defaultStorage.js +131 -0
  147. package/dist/storage/defaultStorage.js.map +1 -0
  148. package/dist/storage/index.d.ts +6 -0
  149. package/dist/storage/index.d.ts.map +1 -0
  150. package/dist/storage/index.js +13 -0
  151. package/dist/storage/index.js.map +1 -0
  152. package/dist/storage/types.d.ts +32 -0
  153. package/dist/storage/types.d.ts.map +1 -0
  154. package/dist/storage/types.js +11 -0
  155. package/dist/storage/types.js.map +1 -0
  156. package/dist/theme/defaults.d.ts +43 -0
  157. package/dist/theme/defaults.d.ts.map +1 -0
  158. package/dist/theme/defaults.js +128 -0
  159. package/dist/theme/defaults.js.map +1 -0
  160. package/dist/theme/index.d.ts +3 -0
  161. package/dist/theme/index.d.ts.map +1 -0
  162. package/dist/theme/index.js +14 -0
  163. package/dist/theme/index.js.map +1 -0
  164. package/dist/theme/types.d.ts +136 -0
  165. package/dist/theme/types.d.ts.map +1 -0
  166. package/dist/theme/types.js +3 -0
  167. package/dist/theme/types.js.map +1 -0
  168. package/dist/types/config.d.ts +100 -0
  169. package/dist/types/config.d.ts.map +1 -0
  170. package/dist/types/config.js +3 -0
  171. package/dist/types/config.js.map +1 -0
  172. package/dist/types/index.d.ts +3 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/types/index.js +3 -0
  175. package/dist/types/index.js.map +1 -0
  176. package/dist/types/openapi.d.ts +601 -0
  177. package/dist/types/openapi.d.ts.map +1 -0
  178. package/dist/types/openapi.js +7 -0
  179. package/dist/types/openapi.js.map +1 -0
  180. package/dist/utils/index.d.ts +2 -0
  181. package/dist/utils/index.d.ts.map +1 -0
  182. package/dist/utils/index.js +6 -0
  183. package/dist/utils/index.js.map +1 -0
  184. package/dist/utils/uuid.d.ts +10 -0
  185. package/dist/utils/uuid.d.ts.map +1 -0
  186. package/dist/utils/uuid.js +60 -0
  187. package/dist/utils/uuid.js.map +1 -0
  188. package/package.json +124 -0
  189. package/src/@types/expo-file-system-legacy.d.ts +13 -0
  190. package/src/api/client.ts +250 -0
  191. package/src/api/errors.ts +84 -0
  192. package/src/api/index.ts +15 -0
  193. package/src/api/retry.ts +99 -0
  194. package/src/attachments/FeedbackSheet.tsx +400 -0
  195. package/src/attachments/index.ts +70 -0
  196. package/src/components/AttachmentGrid.tsx +247 -0
  197. package/src/components/AttachmentPicker.tsx +391 -0
  198. package/src/components/AttachmentPreview.tsx +210 -0
  199. package/src/components/CategorySelector.tsx +174 -0
  200. package/src/components/FeedbackForm.tsx +216 -0
  201. package/src/components/FeedbackSheet.tsx +321 -0
  202. package/src/components/ThemedButton.tsx +127 -0
  203. package/src/components/ThemedText.tsx +65 -0
  204. package/src/components/ThemedTextInput.tsx +65 -0
  205. package/src/components/UploadStatusOverlay.tsx +440 -0
  206. package/src/components/index.ts +39 -0
  207. package/src/context/HarkenContext.tsx +129 -0
  208. package/src/context/index.ts +2 -0
  209. package/src/domain/index.ts +12 -0
  210. package/src/domain/upload-queue.ts +131 -0
  211. package/src/hooks/index.ts +10 -0
  212. package/src/hooks/useAnonymousId.ts +68 -0
  213. package/src/hooks/useAttachmentPicker.ts +243 -0
  214. package/src/hooks/useAttachmentStatus.ts +86 -0
  215. package/src/hooks/useAttachmentUpload.ts +370 -0
  216. package/src/hooks/useFeedback.ts +139 -0
  217. package/src/hooks/useHarkenContext.ts +35 -0
  218. package/src/hooks/useHarkenTheme.ts +36 -0
  219. package/src/index.ts +168 -0
  220. package/src/services/index.ts +11 -0
  221. package/src/services/uploadQueueService.ts +727 -0
  222. package/src/services/uploadQueueStorage.ts +78 -0
  223. package/src/storage/IdentityStore.ts +89 -0
  224. package/src/storage/SecureStoreAdapter.ts +59 -0
  225. package/src/storage/defaultStorage.ts +109 -0
  226. package/src/storage/index.ts +5 -0
  227. package/src/storage/types.ts +34 -0
  228. package/src/theme/defaults.ts +151 -0
  229. package/src/theme/index.ts +23 -0
  230. package/src/theme/types.ts +157 -0
  231. package/src/types/config.ts +112 -0
  232. package/src/types/index.ts +10 -0
  233. package/src/types/openapi.ts +601 -0
  234. package/src/utils/index.ts +1 -0
  235. package/src/utils/uuid.ts +77 -0
@@ -0,0 +1,391 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import {
3
+ View,
4
+ Modal,
5
+ Pressable,
6
+ Dimensions,
7
+ Platform,
8
+ ActionSheetIOS,
9
+ StyleSheet,
10
+ } from 'react-native';
11
+ import type { ViewStyle, StyleProp } from 'react-native';
12
+ import { SafeAreaView } from 'react-native-safe-area-context';
13
+ import { useHarkenTheme } from '../hooks';
14
+ import { ThemedText } from './ThemedText';
15
+
16
+ export type AttachmentSource = 'camera' | 'library' | 'document';
17
+
18
+ /**
19
+ * Configuration for a single picker option.
20
+ */
21
+ export interface PickerOptionConfig {
22
+ /** Custom label text */
23
+ label?: string;
24
+ /** Custom description text */
25
+ description?: string;
26
+ /** Icon background color (defaults to theme accent colors) */
27
+ color?: string;
28
+ /** Custom icon element (replaces default) */
29
+ icon?: React.ReactNode;
30
+ /** Hide this option entirely */
31
+ hidden?: boolean;
32
+ }
33
+
34
+ export interface AttachmentPickerProps {
35
+ /** Whether the picker is visible */
36
+ visible: boolean;
37
+ /** Callback when picker is closed */
38
+ onClose: () => void;
39
+ /** Callback when camera is selected */
40
+ onTakePhoto: () => void;
41
+ /** Callback when photo library is selected */
42
+ onPickFromLibrary: () => void;
43
+ /** Callback when document picker is selected */
44
+ onPickDocument: () => void;
45
+ /** Title shown in the picker */
46
+ title?: string;
47
+ /**
48
+ * Custom render function for option icons.
49
+ * @deprecated Use `options.camera.icon` etc. instead
50
+ */
51
+ renderIcon?: (source: AttachmentSource) => React.ReactNode;
52
+ /** Customize individual picker options */
53
+ options?: {
54
+ camera?: PickerOptionConfig;
55
+ library?: PickerOptionConfig;
56
+ document?: PickerOptionConfig;
57
+ };
58
+ /** Cancel button label */
59
+ cancelLabel?: string;
60
+ /** Overlay background color */
61
+ overlayColor?: string;
62
+ /** Bottom sheet corner radius */
63
+ sheetRadius?: number;
64
+ /** Additional style for bottom sheet container */
65
+ sheetStyle?: StyleProp<ViewStyle>;
66
+ /** Additional style for option rows */
67
+ optionStyle?: StyleProp<ViewStyle>;
68
+ }
69
+
70
+ interface PickerOption {
71
+ key: AttachmentSource;
72
+ label: string;
73
+ description: string;
74
+ color: string;
75
+ icon: React.ReactNode;
76
+ action: () => void;
77
+ hidden: boolean;
78
+ }
79
+
80
+ /**
81
+ * Platform-appropriate attachment source picker.
82
+ *
83
+ * - **iOS**: Uses native `ActionSheetIOS` for platform-native experience
84
+ * - **Android**: Uses a bottom sheet modal with styled options
85
+ *
86
+ * @example
87
+ * ```tsx
88
+ * // Basic usage
89
+ * <AttachmentPicker
90
+ * visible={showPicker}
91
+ * onClose={() => setShowPicker(false)}
92
+ * onTakePhoto={() => pickImage('camera')}
93
+ * onPickFromLibrary={() => pickImage('library')}
94
+ * onPickDocument={() => pickDocument()}
95
+ * />
96
+ *
97
+ * // With customization
98
+ * <AttachmentPicker
99
+ * visible={showPicker}
100
+ * onClose={() => setShowPicker(false)}
101
+ * onTakePhoto={() => pickImage('camera')}
102
+ * onPickFromLibrary={() => pickImage('library')}
103
+ * onPickDocument={() => pickDocument()}
104
+ * title="Attach File"
105
+ * cancelLabel="Dismiss"
106
+ * options={{
107
+ * camera: {
108
+ * label: 'Take Photo',
109
+ * icon: <CameraIcon />,
110
+ * color: '#007AFF',
111
+ * },
112
+ * library: {
113
+ * label: 'Choose Photo',
114
+ * icon: <PhotoIcon />,
115
+ * },
116
+ * document: {
117
+ * hidden: true, // Hide files option
118
+ * },
119
+ * }}
120
+ * />
121
+ * ```
122
+ */
123
+ export function AttachmentPicker({
124
+ visible,
125
+ onClose,
126
+ onTakePhoto,
127
+ onPickFromLibrary,
128
+ onPickDocument,
129
+ title = 'Add Attachment',
130
+ renderIcon,
131
+ options: optionOverrides,
132
+ cancelLabel = 'Cancel',
133
+ overlayColor,
134
+ sheetRadius,
135
+ sheetStyle,
136
+ optionStyle,
137
+ }: AttachmentPickerProps): React.JSX.Element | null {
138
+ const theme = useHarkenTheme();
139
+ const screenHeight = Dimensions.get('window').height;
140
+
141
+ // Prevent double-triggering ActionSheetIOS if callbacks change
142
+ const isShowingRef = useRef(false);
143
+
144
+ // Build options with defaults and overrides
145
+ const options: PickerOption[] = [
146
+ {
147
+ key: 'camera',
148
+ label: optionOverrides?.camera?.label ?? 'Camera',
149
+ description: optionOverrides?.camera?.description ?? 'Take a new photo',
150
+ color: optionOverrides?.camera?.color ?? theme.colors.accent1,
151
+ icon:
152
+ optionOverrides?.camera?.icon ??
153
+ (renderIcon ? renderIcon('camera') : <DefaultIcon emoji="📷" />),
154
+ action: onTakePhoto,
155
+ hidden: optionOverrides?.camera?.hidden ?? false,
156
+ },
157
+ {
158
+ key: 'library',
159
+ label: optionOverrides?.library?.label ?? 'Photo Library',
160
+ description:
161
+ optionOverrides?.library?.description ?? 'Choose from existing photos',
162
+ color: optionOverrides?.library?.color ?? theme.colors.accent2,
163
+ icon:
164
+ optionOverrides?.library?.icon ??
165
+ (renderIcon ? renderIcon('library') : <DefaultIcon emoji="🖼️" />),
166
+ action: onPickFromLibrary,
167
+ hidden: optionOverrides?.library?.hidden ?? false,
168
+ },
169
+ {
170
+ key: 'document',
171
+ label: optionOverrides?.document?.label ?? 'Files',
172
+ description:
173
+ optionOverrides?.document?.description ?? 'Browse documents and files',
174
+ color: optionOverrides?.document?.color ?? theme.colors.accent3,
175
+ icon:
176
+ optionOverrides?.document?.icon ??
177
+ (renderIcon ? renderIcon('document') : <DefaultIcon emoji="📄" />),
178
+ action: onPickDocument,
179
+ hidden: optionOverrides?.document?.hidden ?? false,
180
+ },
181
+ ];
182
+
183
+ const visibleOptions = options.filter((o) => !o.hidden);
184
+
185
+ const handleOptionPress = (action: () => void) => {
186
+ onClose();
187
+ // Small delay to let modal close animation finish
188
+ setTimeout(action, 100);
189
+ };
190
+
191
+ // iOS: Use native ActionSheetIOS
192
+ useEffect(() => {
193
+ if (!visible) {
194
+ isShowingRef.current = false;
195
+ return;
196
+ }
197
+
198
+ if (visible && Platform.OS === 'ios' && !isShowingRef.current) {
199
+ isShowingRef.current = true;
200
+
201
+ // Build iOS action sheet options from visible options
202
+ const iosOptions = [cancelLabel, ...visibleOptions.map((o) => o.label)];
203
+
204
+ ActionSheetIOS.showActionSheetWithOptions(
205
+ {
206
+ options: iosOptions,
207
+ cancelButtonIndex: 0,
208
+ title,
209
+ },
210
+ (buttonIndex) => {
211
+ isShowingRef.current = false;
212
+ onClose();
213
+ if (buttonIndex > 0) {
214
+ const selectedOption = visibleOptions[buttonIndex - 1];
215
+ if (selectedOption) {
216
+ setTimeout(() => selectedOption.action(), 100);
217
+ }
218
+ }
219
+ }
220
+ );
221
+ }
222
+ }, [visible, onClose, visibleOptions, title, cancelLabel]);
223
+
224
+ // iOS: Don't render modal - we use ActionSheetIOS instead
225
+ if (Platform.OS === 'ios') {
226
+ return null;
227
+ }
228
+
229
+ const resolvedOverlayColor = overlayColor ?? theme.colors.overlay;
230
+ const resolvedSheetRadius = sheetRadius ?? theme.radii.xl;
231
+
232
+ // Android: Use bottom sheet modal
233
+ return (
234
+ <Modal
235
+ visible={visible}
236
+ transparent
237
+ animationType="slide"
238
+ onRequestClose={onClose}
239
+ >
240
+ <SafeAreaView style={styles.modalContainer}>
241
+ {/* Background overlay */}
242
+ <Pressable
243
+ style={[styles.overlay, { backgroundColor: resolvedOverlayColor }]}
244
+ onPress={onClose}
245
+ >
246
+ {/* Bottom sheet */}
247
+ <View
248
+ style={[
249
+ styles.bottomSheet,
250
+ {
251
+ backgroundColor: theme.colors.background,
252
+ maxHeight: screenHeight * 0.6,
253
+ borderTopLeftRadius: resolvedSheetRadius,
254
+ borderTopRightRadius: resolvedSheetRadius,
255
+ },
256
+ sheetStyle,
257
+ ]}
258
+ // Prevent touches from passing through to background
259
+ onStartShouldSetResponder={() => true}
260
+ >
261
+ {/* Handle bar */}
262
+ <View style={styles.handleContainer}>
263
+ <View
264
+ style={[
265
+ styles.handle,
266
+ { backgroundColor: theme.colors.textSecondary },
267
+ ]}
268
+ />
269
+ </View>
270
+
271
+ {/* Title */}
272
+ <View style={styles.titleContainer}>
273
+ <ThemedText variant="title" style={styles.title}>
274
+ {title}
275
+ </ThemedText>
276
+ </View>
277
+
278
+ {/* Options */}
279
+ <View style={styles.optionsContainer}>
280
+ {visibleOptions.map((option) => (
281
+ <Pressable
282
+ key={option.key}
283
+ style={({ pressed }) => [
284
+ styles.option,
285
+ {
286
+ backgroundColor: pressed
287
+ ? theme.colors.border
288
+ : theme.colors.backgroundSecondary,
289
+ borderRadius: theme.radii.md,
290
+ },
291
+ optionStyle,
292
+ ]}
293
+ onPress={() => handleOptionPress(option.action)}
294
+ >
295
+ <View
296
+ style={[
297
+ styles.iconContainer,
298
+ {
299
+ backgroundColor: option.color,
300
+ borderRadius: theme.radii.full,
301
+ },
302
+ ]}
303
+ >
304
+ {option.icon}
305
+ </View>
306
+ <View style={styles.optionText}>
307
+ <ThemedText variant="label">{option.label}</ThemedText>
308
+ <ThemedText variant="caption" secondary>
309
+ {option.description}
310
+ </ThemedText>
311
+ </View>
312
+ </Pressable>
313
+ ))}
314
+
315
+ {/* Cancel Button */}
316
+ <Pressable style={styles.cancelButton} onPress={onClose}>
317
+ <ThemedText secondary>{cancelLabel}</ThemedText>
318
+ </Pressable>
319
+ </View>
320
+ </View>
321
+ </Pressable>
322
+ </SafeAreaView>
323
+ </Modal>
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Default emoji icon component.
329
+ */
330
+ function DefaultIcon({ emoji }: { emoji: string }): React.JSX.Element {
331
+ return <ThemedText style={styles.defaultIcon}>{emoji}</ThemedText>;
332
+ }
333
+
334
+ const styles = StyleSheet.create({
335
+ modalContainer: {
336
+ flex: 1,
337
+ },
338
+ overlay: {
339
+ flex: 1,
340
+ justifyContent: 'flex-end',
341
+ },
342
+ bottomSheet: {
343
+ paddingBottom: 20,
344
+ },
345
+ handleContainer: {
346
+ alignItems: 'center',
347
+ paddingVertical: 12,
348
+ },
349
+ handle: {
350
+ width: 36,
351
+ height: 4,
352
+ borderRadius: 2,
353
+ opacity: 0.3,
354
+ },
355
+ titleContainer: {
356
+ paddingHorizontal: 20,
357
+ paddingBottom: 16,
358
+ },
359
+ title: {
360
+ textAlign: 'center',
361
+ },
362
+ optionsContainer: {
363
+ paddingHorizontal: 20,
364
+ },
365
+ option: {
366
+ flexDirection: 'row',
367
+ alignItems: 'center',
368
+ paddingVertical: 16,
369
+ paddingHorizontal: 16,
370
+ marginBottom: 8,
371
+ },
372
+ iconContainer: {
373
+ width: 44,
374
+ height: 44,
375
+ alignItems: 'center',
376
+ justifyContent: 'center',
377
+ marginRight: 16,
378
+ },
379
+ defaultIcon: {
380
+ fontSize: 22,
381
+ },
382
+ optionText: {
383
+ flex: 1,
384
+ gap: 2,
385
+ },
386
+ cancelButton: {
387
+ paddingVertical: 16,
388
+ alignItems: 'center',
389
+ marginTop: 12,
390
+ },
391
+ });
@@ -0,0 +1,210 @@
1
+ import React from 'react';
2
+ import { View, Image, StyleSheet } from 'react-native';
3
+ import type { ViewStyle, StyleProp, ImageStyle } from 'react-native';
4
+ import { useHarkenTheme } from '../hooks';
5
+ import { ThemedText } from './ThemedText';
6
+ import { UploadStatusOverlay } from './UploadStatusOverlay';
7
+ import type { UploadStatusLabels } from './UploadStatusOverlay';
8
+ import { UploadPhase } from '../domain';
9
+
10
+ export interface AttachmentPreviewProps {
11
+ /** Local file URI for preview */
12
+ uri: string;
13
+ /** MIME type of the file */
14
+ mimeType?: string;
15
+ /** File name (shown for non-image files) */
16
+ fileName?: string;
17
+ /** Current upload phase */
18
+ phase: UploadPhase;
19
+ /** Upload progress (0.0 - 1.0) */
20
+ progress: number;
21
+ /** Error message if failed */
22
+ error?: string;
23
+ /** Callback when retry is pressed */
24
+ onRetry?: () => void;
25
+ /** Callback when remove is pressed */
26
+ onRemove?: () => void;
27
+ /** Size of the preview */
28
+ size?: number;
29
+ /** Additional container style */
30
+ style?: StyleProp<ViewStyle>;
31
+ /** Additional image style */
32
+ imageStyle?: StyleProp<ImageStyle>;
33
+ /** Custom renderer for non-image file placeholders */
34
+ renderPlaceholder?: (mimeType: string, fileName?: string) => React.ReactNode;
35
+ /** Custom file icon function (returns string emoji or React node) */
36
+ getFileIcon?: (mimeType: string) => React.ReactNode | string;
37
+ /** Custom labels for upload status overlay */
38
+ statusLabels?: UploadStatusLabels;
39
+ }
40
+
41
+ /**
42
+ * Preview component for a single attachment with upload status.
43
+ *
44
+ * Shows image thumbnail for images, file icon for other types.
45
+ * Includes upload status overlay with progress/retry/remove actions.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * // Basic usage
50
+ * <AttachmentPreview
51
+ * uri={attachment.localUri}
52
+ * mimeType={attachment.mimeType}
53
+ * phase={attachment.phase}
54
+ * progress={attachment.progress}
55
+ * onRetry={() => retryAttachment(attachment.attachmentId)}
56
+ * onRemove={() => removeAttachment(attachment.attachmentId)}
57
+ * />
58
+ *
59
+ * // With custom file icon
60
+ * <AttachmentPreview
61
+ * uri={attachment.localUri}
62
+ * mimeType={attachment.mimeType}
63
+ * phase={attachment.phase}
64
+ * progress={attachment.progress}
65
+ * getFileIcon={(mime) => {
66
+ * if (mime === 'application/pdf') return <PdfIcon />;
67
+ * return '📄';
68
+ * }}
69
+ * />
70
+ *
71
+ * // With custom placeholder
72
+ * <AttachmentPreview
73
+ * uri={attachment.localUri}
74
+ * mimeType={attachment.mimeType}
75
+ * phase={attachment.phase}
76
+ * progress={attachment.progress}
77
+ * renderPlaceholder={(mime, name) => (
78
+ * <MyCustomPlaceholder mimeType={mime} fileName={name} />
79
+ * )}
80
+ * />
81
+ * ```
82
+ */
83
+ export function AttachmentPreview({
84
+ uri,
85
+ mimeType,
86
+ fileName,
87
+ phase,
88
+ progress,
89
+ error,
90
+ onRetry,
91
+ onRemove,
92
+ size = 80,
93
+ style,
94
+ imageStyle,
95
+ renderPlaceholder,
96
+ getFileIcon: customGetFileIcon,
97
+ statusLabels,
98
+ }: AttachmentPreviewProps): React.JSX.Element {
99
+ const theme = useHarkenTheme();
100
+ const isImage = mimeType?.startsWith('image/') ?? true;
101
+
102
+ const renderFileContent = () => {
103
+ if (renderPlaceholder && mimeType) {
104
+ return renderPlaceholder(mimeType, fileName);
105
+ }
106
+
107
+ const icon = customGetFileIcon
108
+ ? customGetFileIcon(mimeType ?? '')
109
+ : getDefaultFileIcon(mimeType);
110
+
111
+ return (
112
+ <View style={styles.filePreview}>
113
+ {typeof icon === 'string' ? (
114
+ <ThemedText style={styles.fileIcon}>{icon}</ThemedText>
115
+ ) : (
116
+ icon
117
+ )}
118
+ {fileName && (
119
+ <ThemedText
120
+ variant="caption"
121
+ secondary
122
+ numberOfLines={2}
123
+ style={styles.fileName}
124
+ >
125
+ {fileName}
126
+ </ThemedText>
127
+ )}
128
+ </View>
129
+ );
130
+ };
131
+
132
+ return (
133
+ <View
134
+ style={[
135
+ styles.container,
136
+ {
137
+ width: size,
138
+ height: size,
139
+ borderRadius: theme.radii.md,
140
+ backgroundColor: theme.colors.backgroundSecondary,
141
+ borderWidth: 1,
142
+ borderColor: theme.colors.border,
143
+ overflow: 'hidden',
144
+ },
145
+ style,
146
+ ]}
147
+ >
148
+ {isImage ? (
149
+ <Image
150
+ source={{ uri }}
151
+ style={[styles.image, { width: size, height: size }, imageStyle]}
152
+ resizeMode="cover"
153
+ />
154
+ ) : (
155
+ renderFileContent()
156
+ )}
157
+
158
+ <UploadStatusOverlay
159
+ phase={phase}
160
+ progress={progress}
161
+ error={error}
162
+ onRetry={onRetry}
163
+ onRemove={onRemove}
164
+ labels={statusLabels}
165
+ />
166
+ </View>
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Get default file icon emoji based on MIME type.
172
+ */
173
+ function getDefaultFileIcon(mimeType?: string): string {
174
+ if (!mimeType) return '📄';
175
+
176
+ if (mimeType.startsWith('image/')) return '🖼️';
177
+ if (mimeType.startsWith('video/')) return '🎬';
178
+ if (mimeType === 'application/pdf') return '📕';
179
+ if (mimeType.includes('spreadsheet') || mimeType.includes('excel'))
180
+ return '📊';
181
+ if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
182
+ if (mimeType.includes('presentation') || mimeType.includes('powerpoint'))
183
+ return '📽️';
184
+ if (mimeType.includes('zip') || mimeType.includes('archive')) return '📦';
185
+
186
+ return '📄';
187
+ }
188
+
189
+ const styles = StyleSheet.create({
190
+ container: {
191
+ position: 'relative',
192
+ },
193
+ image: {
194
+ backgroundColor: '#f0f0f0',
195
+ },
196
+ filePreview: {
197
+ flex: 1,
198
+ alignItems: 'center',
199
+ justifyContent: 'center',
200
+ padding: 8,
201
+ },
202
+ fileIcon: {
203
+ fontSize: 28,
204
+ },
205
+ fileName: {
206
+ marginTop: 4,
207
+ textAlign: 'center',
208
+ fontSize: 10,
209
+ },
210
+ });