Skip to content

Commit 2af97a0

Browse files
authored
Merge pull request #21 from typelets/feature/mobile-file-attachments
feature/mobile file attachments
2 parents 7f1da6b + cd29aae commit 2af97a0

File tree

14 files changed

+1291
-51
lines changed

14 files changed

+1291
-51
lines changed

apps/mobile/v1/package-lock.json

Lines changed: 26 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/mobile/v1/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@
2525
"expo-constants": "~18.0.9",
2626
"expo-crypto": "^15.0.7",
2727
"expo-dev-client": "~6.0.12",
28+
"expo-document-picker": "~14.0.7",
29+
"expo-file-system": "~19.0.17",
2830
"expo-font": "~14.0.8",
2931
"expo-haptics": "~15.0.7",
3032
"expo-image": "~3.0.8",
3133
"expo-linking": "~8.0.8",
3234
"expo-router": "~6.0.8",
3335
"expo-secure-store": "^15.0.7",
36+
"expo-sharing": "~14.0.7",
3437
"expo-splash-screen": "~31.0.10",
3538
"expo-status-bar": "~3.0.8",
3639
"expo-symbols": "~1.0.7",
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import React, { useState } from 'react';
2+
import {
3+
View,
4+
Text,
5+
StyleSheet,
6+
TouchableOpacity,
7+
ActivityIndicator,
8+
Alert,
9+
} from 'react-native';
10+
import { Ionicons } from '@expo/vector-icons';
11+
import * as Haptics from 'expo-haptics';
12+
import { useTheme } from '../theme';
13+
import { useApiService, type FileAttachment } from '../services/api';
14+
15+
interface FileUploadProps {
16+
noteId: string;
17+
attachments?: FileAttachment[];
18+
onUploadComplete?: (attachments: FileAttachment[]) => void;
19+
onDeleteComplete?: () => void;
20+
}
21+
22+
export function FileUpload({
23+
noteId,
24+
attachments = [],
25+
onUploadComplete,
26+
onDeleteComplete,
27+
}: FileUploadProps) {
28+
const theme = useTheme();
29+
const api = useApiService();
30+
const [isUploading, setIsUploading] = useState(false);
31+
const [uploadProgress, setUploadProgress] = useState(0);
32+
const [deletingIds, setDeletingIds] = useState<string[]>([]);
33+
34+
const handlePickFiles = async () => {
35+
try {
36+
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
37+
const files = await api.pickFiles();
38+
39+
if (files.length === 0) {
40+
return;
41+
}
42+
43+
setIsUploading(true);
44+
setUploadProgress(0);
45+
46+
const uploadedFiles = await api.uploadFiles(noteId, files, (progress) => {
47+
setUploadProgress(progress.percentage);
48+
});
49+
50+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
51+
onUploadComplete?.(uploadedFiles);
52+
} catch (error) {
53+
console.error('Upload error:', error);
54+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
55+
Alert.alert('Upload Failed', error instanceof Error ? error.message : 'Failed to upload files');
56+
} finally {
57+
setIsUploading(false);
58+
setUploadProgress(0);
59+
}
60+
};
61+
62+
const handleDownload = async (attachment: FileAttachment) => {
63+
try {
64+
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
65+
66+
const fileUri = await api.downloadFile(attachment);
67+
await api.shareFile(fileUri);
68+
69+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
70+
} catch (error) {
71+
console.error('Download error:', error);
72+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
73+
Alert.alert('Download Failed', error instanceof Error ? error.message : 'Failed to download file');
74+
}
75+
};
76+
77+
const handleDelete = async (attachment: FileAttachment) => {
78+
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
79+
80+
Alert.alert(
81+
'Delete Attachment',
82+
`Delete ${attachment.originalName}?`,
83+
[
84+
{ text: 'Cancel', style: 'cancel' },
85+
{
86+
text: 'Delete',
87+
style: 'destructive',
88+
onPress: async () => {
89+
try {
90+
setDeletingIds((prev) => [...prev, attachment.id]);
91+
92+
await api.deleteAttachment(attachment.id);
93+
94+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
95+
onDeleteComplete?.();
96+
} catch (error) {
97+
console.error('Delete error:', error);
98+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
99+
Alert.alert('Delete Failed', error instanceof Error ? error.message : 'Failed to delete attachment');
100+
} finally {
101+
setDeletingIds((prev) => prev.filter((id) => id !== attachment.id));
102+
}
103+
},
104+
},
105+
]
106+
);
107+
};
108+
109+
const styles = createStyles(theme);
110+
111+
return (
112+
<View style={styles.container}>
113+
{/* Upload Button */}
114+
<TouchableOpacity
115+
style={[styles.uploadButton, { borderColor: theme.colors.border }]}
116+
onPress={handlePickFiles}
117+
disabled={isUploading}
118+
>
119+
<View style={styles.uploadContent}>
120+
{isUploading ? (
121+
<>
122+
<ActivityIndicator size="small" color={theme.colors.primary} />
123+
<Text style={[styles.uploadText, { color: theme.colors.mutedForeground }]}>
124+
Uploading... {uploadProgress.toFixed(0)}%
125+
</Text>
126+
</>
127+
) : (
128+
<>
129+
<Ionicons name="cloud-upload-outline" size={24} color={theme.colors.mutedForeground} />
130+
<Text style={[styles.uploadText, { color: theme.colors.mutedForeground }]}>
131+
Tap to attach files
132+
</Text>
133+
</>
134+
)}
135+
</View>
136+
</TouchableOpacity>
137+
138+
{/* Attachments List */}
139+
{attachments.length > 0 && (
140+
<View style={styles.attachmentsList}>
141+
<Text style={[styles.attachmentsTitle, { color: theme.colors.foreground }]}>
142+
Attachments ({attachments.length})
143+
</Text>
144+
145+
{attachments.map((attachment) => {
146+
const isDeleting = deletingIds.includes(attachment.id);
147+
const icon = api.getFileIcon(attachment.mimeType);
148+
149+
return (
150+
<View
151+
key={attachment.id}
152+
style={[
153+
styles.attachmentItem,
154+
{
155+
backgroundColor: theme.colors.card,
156+
borderColor: theme.colors.border,
157+
},
158+
]}
159+
>
160+
<Text style={styles.fileIcon}>{icon}</Text>
161+
162+
<View style={styles.fileInfo}>
163+
<Text
164+
style={[styles.fileName, { color: theme.colors.foreground }]}
165+
numberOfLines={1}
166+
>
167+
{attachment.originalName}
168+
</Text>
169+
<Text style={[styles.fileSize, { color: theme.colors.mutedForeground }]}>
170+
{api.formatFileSize(attachment.size)}
171+
</Text>
172+
</View>
173+
174+
<View style={styles.actions}>
175+
<TouchableOpacity
176+
style={[styles.actionButton, { backgroundColor: theme.colors.muted }]}
177+
onPress={() => handleDownload(attachment)}
178+
disabled={isDeleting}
179+
>
180+
<Ionicons
181+
name="download-outline"
182+
size={16}
183+
color={theme.colors.mutedForeground}
184+
/>
185+
</TouchableOpacity>
186+
187+
<TouchableOpacity
188+
style={[styles.actionButton, { backgroundColor: theme.colors.muted }]}
189+
onPress={() => handleDelete(attachment)}
190+
disabled={isDeleting}
191+
>
192+
{isDeleting ? (
193+
<ActivityIndicator size="small" color={theme.colors.mutedForeground} />
194+
) : (
195+
<Ionicons name="trash-outline" size={16} color="#EF4444" />
196+
)}
197+
</TouchableOpacity>
198+
</View>
199+
</View>
200+
);
201+
})}
202+
</View>
203+
)}
204+
</View>
205+
);
206+
}
207+
208+
const createStyles = (theme: any) =>
209+
StyleSheet.create({
210+
container: {
211+
gap: 12,
212+
},
213+
uploadButton: {
214+
borderWidth: 1,
215+
borderStyle: 'dashed',
216+
borderRadius: 8,
217+
padding: 16,
218+
},
219+
uploadContent: {
220+
flexDirection: 'row',
221+
alignItems: 'center',
222+
justifyContent: 'center',
223+
gap: 8,
224+
},
225+
uploadText: {
226+
fontSize: 14,
227+
},
228+
attachmentsList: {
229+
gap: 8,
230+
},
231+
attachmentsTitle: {
232+
fontSize: 14,
233+
fontWeight: '600',
234+
marginBottom: 4,
235+
},
236+
attachmentItem: {
237+
flexDirection: 'row',
238+
alignItems: 'center',
239+
padding: 12,
240+
borderRadius: 8,
241+
borderWidth: 1,
242+
gap: 12,
243+
},
244+
fileIcon: {
245+
fontSize: 20,
246+
},
247+
fileInfo: {
248+
flex: 1,
249+
minWidth: 0,
250+
},
251+
fileName: {
252+
fontSize: 14,
253+
fontWeight: '500',
254+
marginBottom: 2,
255+
},
256+
fileSize: {
257+
fontSize: 12,
258+
},
259+
actions: {
260+
flexDirection: 'row',
261+
gap: 8,
262+
},
263+
actionButton: {
264+
width: 32,
265+
height: 32,
266+
borderRadius: 16,
267+
alignItems: 'center',
268+
justifyContent: 'center',
269+
},
270+
});

0 commit comments

Comments
 (0)