Enhance file upload functionality with document scanning support and improved error handling

This commit is contained in:
Mo Tarbin
2026-08-09 12:00:30 -04:00
parent 731c46b26a
commit 841f83d6ae
6 changed files with 426 additions and 181 deletions

View File

@@ -1,14 +1,15 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({
entityType = 'chore_attachment',
entityId,
draftId,
entityId,
entityType = 'chore_attachment',
} = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
@@ -19,28 +20,36 @@ export const useFileUpload = ({
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
// Only images go through compression — anything else (PDFs, docs)
// would be destroyed by re-encoding it as a JPEG.
let fileToUpload = file
if (file.type?.startsWith('image/')) {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(
file,
compressionOptions,
)
fileToUpload = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('file', fileToUpload)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', String(entityId))
if (draftId) formData.append('draftId', draftId)
@@ -62,7 +71,7 @@ export const useFileUpload = ({
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
message: 'File uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
@@ -74,7 +83,7 @@ export const useFileUpload = ({
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
message: 'Failed to upload file.',
})
return null
}
@@ -91,7 +100,7 @@ export const useFileUpload = ({
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
message: 'An error occurred while processing the file.',
})
return null
}

17
src/utils/FileConvert.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Turns an image source the scanners produce — a base64 data URI on iOS/web,
* a Capacitor localhost URL on Android — into a File the upload endpoint
* accepts. Both forms are fetchable, so one path covers them.
*/
export async function imageSourceToFile(source, fileName = 'scan.jpg') {
if (!source) return null
try {
const response = await fetch(source)
const blob = await response.blob()
const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
return new File([blob], fileName, { type })
} catch (e) {
console.error('[FileConvert] failed to convert image source:', e)
return null
}
}

View File

@@ -3,6 +3,7 @@ import {
ArrowDropDown,
AttachFile,
Delete,
DocumentScanner,
HorizontalRule,
Save,
UploadFile,
@@ -41,6 +42,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import DurationInput from '../../components/common/DurationInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import {
useArchiveChore,
useChore,
@@ -59,6 +61,7 @@ import {
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
import Priorities from '../../utils/Priorities.jsx'
@@ -173,6 +176,7 @@ const ChoreEdit = () => {
const { data: membersData, isLoading: isMemberDataLoading } =
useCircleMembers()
const { showError, showSuccess } = useNotification()
const { isNativeScanner, scanDocument } = useDocumentScanner()
const [userLabels, setUserLabels] = useState([])
@@ -671,6 +675,67 @@ const ChoreEdit = () => {
}
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
const uploadAttachmentFile = async file => {
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(file, 'chore_attachment_draft', {
draftId,
})
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
}
}
// Native only: the OS scanner returns a cropped, deskewed page which is a
// better attachment than a raw camera shot of the same document.
const handleScanAttachment = async () => {
const { cancelled, error, image } = await scanDocument()
if (cancelled) return
if (error || !image) {
showError({
title: 'Scan Failed',
message: error || 'Could not scan the document.',
})
return
}
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
if (!file) {
showError({
title: 'Scan Failed',
message: 'Could not read the scanned image.',
})
return
}
await uploadAttachmentFile(file)
}
const handleDelete = () => {
setConfirmModelConfig({
isOpen: true,
@@ -1109,62 +1174,39 @@ const ChoreEdit = () => {
))}
</Box>
)}
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={isUploadingAttachment ? null : <UploadFile />}
loading={isUploadingAttachment}
sx={{ alignSelf: 'flex-start' }}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(
file,
'chore_attachment_draft',
{ draftId },
)
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'flex-start' }}>
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={isUploadingAttachment ? null : <UploadFile />}
loading={isUploadingAttachment}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
e.target.value = ''
}
}}
/>
</Button>
await uploadAttachmentFile(file)
}}
/>
</Button>
{isNativeScanner && (
<Button
variant='outlined'
color='neutral'
size='sm'
startDecorator={<DocumentScanner />}
disabled={isUploadingAttachment}
onClick={handleScanAttachment}
>
Scan
</Button>
)}
</Box>
</Card>
</Box>
</Box>

View File

@@ -1,18 +1,32 @@
import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useFileUpload } from '../../hooks/useFileUpload'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService'
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import { CreateLabel } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount } from '../../utils/Helpers'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import {
parseAssignees,
parseDueDate,
@@ -21,19 +35,6 @@ import {
parsePriority,
parseRepeatV2,
} from './CustomParsers'
import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService'
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
@@ -42,6 +43,7 @@ import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel'
import SmartTaskTitleInput from './SmartTaskTitleInput'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
@@ -106,7 +108,7 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
@@ -190,6 +192,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const [showScan, setShowScan] = useState(false)
const [scanAutoCapture, setScanAutoCapture] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
const [isAttachingScan, setIsAttachingScan] = useState(false)
const [llmAvailable, setLlmAvailable] = useState(false)
const [showVoice, setShowVoice] = useState(false)
const [voiceAvailable, setVoiceAvailable] = useState(false)
@@ -213,6 +216,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
primaryAction: null,
})
const { isNativeScanner } = useDocumentScanner()
const { uploadFile } = useFileUpload({
entityType: 'chore_attachment_draft',
draftId,
})
useEffect(() => {
localAIService.isAvailable().then(setLlmAvailable)
@@ -274,11 +281,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
isModalOpen,
hasDescription,
dueDate,
createChore,
dueDate,
handleCloseModal,
hasDescription,
isModalOpen,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -709,11 +716,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
createChore()
}
// The scan keeps its source image when asked: upload it against the draft so
// the server promotes it onto the chore the same way manual uploads are.
const attachScannedImage = async imageSource => {
// Creating the chore promotes whatever draft attachments exist at that
// moment, so Create waits on this upload rather than orphaning it.
setIsAttachingScan(true)
try {
const file = await imageSourceToFile(
imageSource,
`scan-${Date.now()}.jpg`,
)
if (!file) return
const uploaded = await uploadFile(file)
if (!uploaded) return
setAttachments(prev => [
...prev,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
} finally {
setIsAttachingScan(false)
}
}
const handleTaskExtracted = ({
taskName,
attachmentImage,
description: extractedDesc,
dueDate: extractedDue,
taskName,
}) => {
if (attachmentImage) {
attachScannedImage(attachmentImage)
}
if (taskName) {
processText(taskName)
}
@@ -801,6 +835,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
setIsAttachingScan(false)
setTaskText('')
setTaskTitle('')
setDueDate(null)
@@ -829,6 +864,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
const createChore = () => {
// A scanned attachment still uploading would be orphaned by the create
if (isAttachingScan) return
// Handle different assignee scenarios
let finalAssignees = assignees
let finalAssignedTo = null
@@ -988,7 +1026,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<Button
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
loading={isAttachingScan}
disabled={!taskTitle.trim() || isAttachingScan}
onClick={createChore}
>
Create
@@ -1359,6 +1398,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
<ScanPanel
open
autoCapture={scanAutoCapture}
canKeepImage={isPlusAccount(userProfile)}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onStateChange={setScanState}

View File

@@ -1,4 +1,12 @@
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
import {
AttachFile,
Close,
DeleteOutline,
DocumentScanner,
Image,
InsertDriveFile,
PhotoCamera,
} from '@mui/icons-material'
import {
Box,
Button,
@@ -9,23 +17,41 @@ import {
} from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useFileUpload } from '../../hooks/useFileUpload'
import { useNotification } from '../../service/NotificationProvider'
import { DeleteDraftAttachment } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const isImageAttachment = attachment => {
const ext = (attachment?.name || '').split('.').pop()?.toLowerCase()
return IMAGE_EXTENSIONS.includes(ext)
}
const AttachmentPickerField = ({
attachments = [],
draftId,
emptyDisplay = 'icon-text',
entityId,
entityType = 'chore_attachment',
onChange,
onClear,
emptyDisplay = 'icon-text',
entityType = 'chore_attachment',
entityId,
draftId,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
const { isNativeScanner, scanDocument } = useDocumentScanner()
const { showError } = useNotification()
// Without a native scanner, `capture` asks a phone for its camera directly.
// Desktop browsers ignore it and fall back to the file picker, which would
// duplicate "Image", so the button only appears on touch devices.
const canTakePhoto = isNativeScanner || navigator.maxTouchPoints > 0
useEffect(() => {
if (!isOpen) return
@@ -36,27 +62,60 @@ const AttachmentPickerField = ({
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
const handleAddFile = () => {
const upload = async file => {
setIsUploading(true)
try {
const uploaded = await uploadFile(file)
if (uploaded) {
onChange([
...attachments,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
}
} finally {
setIsUploading(false)
}
}
const handlePickFile = ({ accept, capture } = {}) => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
if (accept) input.setAttribute('accept', accept)
if (capture) input.setAttribute('capture', capture)
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
setIsUploading(true)
try {
const uploaded = await uploadFile(file)
if (uploaded) {
onChange([
...attachments,
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
])
}
} finally {
setIsUploading(false)
}
if (file) upload(file)
}
input.click()
}
// Native builds get the OS document scanner (edge detection + perspective
// correction); everywhere else "take photo" is the camera roll shortcut.
const handleScan = async () => {
if (!isNativeScanner) {
handlePickFile({ accept: 'image/*', capture: 'environment' })
return
}
const { cancelled, error, image } = await scanDocument()
if (cancelled) return
if (error || !image) {
showError({
title: 'Scan Failed',
message: error || 'Could not scan the document.',
})
return
}
setIsUploading(true)
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
setIsUploading(false)
if (!file) {
showError({
title: 'Scan Failed',
message: 'Could not read the scanned image.',
})
return
}
await upload(file)
}
const handleRemove = async index => {
@@ -199,26 +258,30 @@ const AttachmentPickerField = ({
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
{isImageAttachment(attachment) && (
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
)}
<Box
sx={{
display: 'none',
display: isImageAttachment(attachment)
? 'none'
: 'flex',
width: 36,
height: 36,
alignItems: 'center',
@@ -228,7 +291,15 @@ const AttachmentPickerField = ({
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
{isImageAttachment(attachment) ? (
<Image
sx={{ fontSize: 20, color: 'text.tertiary' }}
/>
) : (
<InsertDriveFile
sx={{ fontSize: 20, color: 'text.tertiary' }}
/>
)}
</Box>
<Typography
level='body-xs'
@@ -255,26 +326,64 @@ const AttachmentPickerField = ({
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
{isUploading ? (
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
disabled
startDecorator={
<CircularProgress
size='sm'
sx={{ '--CircularProgress-size': '14px' }}
/>
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
}
>
Uploading
</Button>
) : (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{canTakePhoto && (
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={
isNativeScanner ? (
<DocumentScanner sx={{ fontSize: 16 }} />
) : (
<PhotoCamera sx={{ fontSize: 16 }} />
)
}
onClick={handleScan}
>
{isNativeScanner ? 'Scan' : 'Photo'}
</Button>
)}
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={<Image sx={{ fontSize: 16 }} />}
onClick={() => handlePickFile({ accept: 'image/*' })}
>
Image
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
startDecorator={<AttachFile sx={{ fontSize: 16 }} />}
onClick={() => handlePickFile()}
>
File
</Button>
</Box>
)}
</Sheet>
</ClickAwayListener>
</Popper>

View File

@@ -8,11 +8,13 @@ import {
import {
Box,
Button,
Checkbox,
CircularProgress,
LinearProgress,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useScanToTask } from './useScanToTask'
/**
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
* belongs to the capture surface and drives a hidden input in this subtree.
*/
const ScanPanel = ({
open,
onTaskExtracted,
autoCapture,
canKeepImage = false,
initialImageUrl,
onClose,
onStateChange,
initialImageUrl,
autoCapture,
onTaskExtracted,
open,
}) => {
const {
isNativeScanner,
phase,
capturedImage,
ocrProgress,
taskResult,
errorMsg,
activate,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
capturedImage,
errorMsg,
fileInputRef,
handleFileSelect,
handleNativeScan,
retake,
activate,
isNativeScanner,
ocrProgress,
phase,
reset,
retake,
startCamera,
stopCamera,
taskResult,
videoRef,
} = useScanToTask()
// The scanned page is usually the task's source of truth (the bill, the
// notice), so keeping it is the default — the OCR text alone loses it.
const [keepImage, setKeepImage] = useState(false)
// Start/stop based on open state
useEffect(() => {
if (open) {
@@ -82,7 +89,10 @@ const ScanPanel = ({
// Auto-close and populate when done
useEffect(() => {
if (phase === 'done' && taskResult) {
onTaskExtracted(taskResult)
onTaskExtracted({
...taskResult,
attachmentImage: canKeepImage && keepImage ? capturedImage : null,
})
onClose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -137,6 +147,18 @@ const ScanPanel = ({
const isProcessing = phase === 'processing'
// Attachments are a Plus feature; without it the upload would only ever
// surface an upgrade error, so the choice isn't offered at all.
const keepImageToggle = !canKeepImage ? null : (
<Checkbox
size='sm'
checked={keepImage}
onChange={e => setKeepImage(e.target.checked)}
label='Keep photo as attachment'
sx={{ '--Checkbox-size': '18px' }}
/>
)
return (
<Box>
{/* ── Capture phase ── */}
@@ -201,16 +223,18 @@ const ScanPanel = ({
)}
</Box>
{/* Hidden when Upload is already the footer's primary action */}
{(isNativeScanner || cameraAvailable) && (
<Box
sx={{
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box
sx={{
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1,
}}
>
{/* Hidden when Upload is already the footer's primary action */}
{(isNativeScanner || cameraAvailable) && (
<Button
size='sm'
variant='plain'
@@ -220,8 +244,9 @@ const ScanPanel = ({
>
Upload
</Button>
</Box>
)}
)}
{keepImageToggle}
</Box>
</>
)}
@@ -285,6 +310,9 @@ const ScanPanel = ({
sx={{ width: '100%' }}
/>
)}
{/* Still editable here — the choice is only read once the task lands */}
{keepImageToggle}
</Box>
)}