feat(mobile): add retry for failed uploads via tap and long-press
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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=<filename>` 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:
|
||||
|
||||
@@ -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() {
|
||||
</View>
|
||||
) : (
|
||||
uploads.map((item) => (
|
||||
<UploadRow key={item.id} item={item} />
|
||||
<UploadRow key={item.id} item={item} onRetry={retryUpload} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
@@ -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<UploadItem["status"], string> = {
|
||||
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 (
|
||||
<View style={rowStyles.row}>
|
||||
<Pressable
|
||||
onLongPress={handleLongPress}
|
||||
onPress={canRetry ? () => 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}
|
||||
>
|
||||
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
@@ -317,10 +367,15 @@ function UploadRow({ item }: { item: UploadItem }) {
|
||||
</Text>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<Text style={rowStyles.statusError}>{item.error}</Text>
|
||||
<View>
|
||||
<Text style={rowStyles.statusError}>{item.error}</Text>
|
||||
{canRetry && (
|
||||
<Text style={rowStyles.retryHint}>Tap to retry</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user