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 = () => { ))} )} - + await uploadAttachmentFile(file) + }} + /> + + {isNativeScanner && ( + + )} + 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 && ( + + )} + + + + )} 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) && ( - - )} + )} + {keepImageToggle} + )} @@ -285,6 +310,9 @@ const ScanPanel = ({ sx={{ width: '100%' }} /> )} + + {/* Still editable here — the choice is only read once the task lands */} + {keepImageToggle} )}