From d322ec6dc74ec47e9beba4a9915ad467b8ad85c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:35:13 +0000 Subject: [PATCH] feat(mobile): add retry for failed uploads via tap and long-press Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/MobileApp.md | 9 ++++ mobile/src/screens/UploadScreen.tsx | 68 ++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 5964e665..27c0aa80 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -193,6 +193,15 @@ The URL may arrive as a standard `file://` path **or** under the app's custom `d After a file is uploaded the app polls `/api/files?search=` every 5 seconds to find the corresponding `FileRecord`, then polls `/api/files/{id}` to track the processing status in real time. Polling stops automatically once the status reaches a terminal state (`completed`, `failed`, or `duplicate`). +#### Retrying failed uploads + +If a file upload fails (e.g. due to network issues or a server error), the failed item stays visible in the upload list with an error message and a **"Tap to retry"** hint. Users can retry the upload in two ways: + +- **Tap** the failed item to immediately retry the upload. +- **Long-press** the failed item to see a confirmation dialog with a **Retry** option. + +The retry re-uses the original file URI so no re-selection is needed. + ## Mobile API Endpoints The backend exposes a dedicated `/api/mobile/` namespace: diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index 727a1d56..0a2788cf 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -43,6 +43,10 @@ interface UploadItem { fileId?: number; /** Actual server-side processing status (e.g. "pending", "processing", "completed"). */ serverStatus?: string; + /** Original file URI – retained so the upload can be retried on failure. */ + uri?: string; + /** MIME type of the original file. */ + mimeType?: string; } export default function UploadScreen() { @@ -63,7 +67,7 @@ export default function UploadScreen() { const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => { const id = `${Date.now()}-${filename}`; - setUploads((prev) => [{ id, filename, status: "uploading" }, ...prev]); + setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]); try { const resp = await api.uploadFile(uri, filename, mimeType); @@ -82,6 +86,35 @@ export default function UploadScreen() { } }, []); + const retryUpload = useCallback(async (item: UploadItem) => { + if (!item.uri) return; + + // Reset the item to "uploading" and clear previous error/server state. + setUploads((prev) => + prev.map((u) => + u.id === item.id + ? { ...u, status: "uploading" as const, error: undefined, serverStatus: undefined, fileId: undefined, taskId: undefined, originalFilename: undefined } + : u + ) + ); + + try { + const resp = await api.uploadFile(item.uri, item.filename, item.mimeType); + setUploads((prev) => + prev.map((u) => + u.id === item.id + ? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename } + : u + ) + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Upload failed"; + setUploads((prev) => + prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u)) + ); + } + }, []); + // --------------------------------------------------------------------------- // Polling – check server-side processing status every 5 seconds // --------------------------------------------------------------------------- @@ -262,7 +295,7 @@ export default function UploadScreen() { ) : ( uploads.map((item) => ( - + )) )} @@ -270,7 +303,7 @@ export default function UploadScreen() { ); } -function UploadRow({ item }: { item: UploadItem }) { +function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) { const uploadIcons: Record = { pending: "⏳", uploading: "⬆️", @@ -290,8 +323,25 @@ function UploadRow({ item }: { item: UploadItem }) { return labels[s] ?? s; } + const canRetry = item.status === "error" && !!item.uri; + + function handleLongPress() { + if (!canRetry) return; + Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [ + { text: "Cancel", style: "cancel" }, + { text: "Retry", onPress: () => onRetry(item) }, + ]); + } + return ( - + onRetry(item) : undefined} + style={rowStyles.row} + accessibilityRole={canRetry ? "button" : "none"} + accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined} + accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined} + > {uploadIcons[item.status]} @@ -317,10 +367,15 @@ function UploadRow({ item }: { item: UploadItem }) { )} {item.status === "error" && ( - {item.error} + + {item.error} + {canRetry && ( + Tap to retry + )} + )} - + ); } @@ -399,4 +454,5 @@ const rowStyles = StyleSheet.create({ statusDone: { fontSize: 12, color: "#059669" }, statusQueued: { fontSize: 12, color: "#6b7280" }, statusError: { fontSize: 12, color: "#dc2626" }, + retryHint: { fontSize: 12, color: "#1e40af", fontWeight: "600", marginTop: 4 }, });