diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js
index 972a11e..ec84b7e 100644
--- a/src/hooks/useFileUpload.js
+++ b/src/hooks/useFileUpload.js
@@ -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
}
diff --git a/src/utils/FileConvert.js b/src/utils/FileConvert.js
new file mode 100644
index 0000000..b61e2f1
--- /dev/null
+++ b/src/utils/FileConvert.js
@@ -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
+ }
+}
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index e7adf3a..f53b9c1 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -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 = () => {
))}
)}
- }
- loading={isUploadingAttachment}
- sx={{ alignSelf: 'flex-start' }}
- >
- Upload File
- {
- 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)
+
+ }
+ loading={isUploadingAttachment}
+ >
+ Upload File
+ {
+ const file = e.target.files[0]
e.target.value = ''
- }
- }}
- />
-
+ await uploadAttachmentFile(file)
+ }}
+ />
+
+ {isNativeScanner && (
+ }
+ disabled={isUploadingAttachment}
+ onClick={handleScanAttachment}
+ >
+ Scan
+
+ )}
+
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 5f6270a..500f7a9 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -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 }) => {
+ }
+ >
+ Uploading…
+
+ ) : (
+
+ {canTakePhoto && (
+
+ ) : (
+
+ )
+ }
+ onClick={handleScan}
+ >
+ {isNativeScanner ? 'Scan' : 'Photo'}
+
+ )}
+ }
+ onClick={() => handlePickFile({ accept: 'image/*' })}
+ >
+ Image
+
+ }
+ onClick={() => handlePickFile()}
+ >
+ File
+
+
+ )}
diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx
index dc2c27c..81ab9b6 100644
--- a/src/views/components/ScanToTask/ScanPanel.jsx
+++ b/src/views/components/ScanToTask/ScanPanel.jsx
@@ -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 : (
+ setKeepImage(e.target.checked)}
+ label='Keep photo as attachment'
+ sx={{ '--Checkbox-size': '18px' }}
+ />
+ )
+
return (
{/* ── Capture phase ── */}
@@ -201,16 +223,18 @@ const ScanPanel = ({
)}
- {/* Hidden when Upload is already the footer's primary action */}
- {(isNativeScanner || cameraAvailable) && (
-
+
+ {/* Hidden when Upload is already the footer's primary action */}
+ {(isNativeScanner || cameraAvailable) && (
Upload
-
- )}
+ )}
+ {keepImageToggle}
+
>
)}
@@ -285,6 +310,9 @@ const ScanPanel = ({
sx={{ width: '100%' }}
/>
)}
+
+ {/* Still editable here — the choice is only read once the task lands */}
+ {keepImageToggle}
)}