diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 01fa153..5791b71 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,4 +1,4 @@ -import { Add, CameraAlt } from '@mui/icons-material' +import { Add } from '@mui/icons-material' import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' @@ -28,7 +28,8 @@ import DueDatePickerField from './DueDatePickerField' import LabelsPickerField from './LabelsPickerField' import LearnMoreButton from './LearnMore' import NotificationPickerField from './NotificationPickerField' -import PhotoTaskModal from './PhotoTaskModal' +import ScanPanel from './ScanToTask/ScanPanel' +import { useDocumentScanner } from '../../hooks/useDocumentScanner' import PriorityPickerField from './PriorityPickerField' import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' @@ -104,7 +105,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) const [attachments, setAttachments] = useState([]) - const [photoModalOpen, setPhotoModalOpen] = useState(false) + const [showScan, setShowScan] = useState(false) + const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null) + const { isNativeScanner } = useDocumentScanner() // Priority colors const priorityColors = { @@ -564,6 +567,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const handleCloseModal = forceRefetch => { onClose(forceRefetch) + setShowScan(false) setTaskText('') setTaskTitle('') setDueDate(null) @@ -730,263 +734,261 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } > - - - Task in a sentence: - - - This feature lets you create a task simply by typing a - sentence. It attempt parses the sentence to identify the - task's due date, priority, and frequency. - + {!showScan && ( + <> + + + Task in a sentence: + + + This feature lets you create a task simply by typing a + sentence. It attempt parses the sentence to identify the + task's due date, priority, and frequency. + - - Examples: - + + Examples: + - -
  • - Priority:For highest priority any of the - following keyword P1, Urgent,{' '} - Important, or ASAP. For lower priorities, - use P2, P3, or P4. -
  • -
  • - Due date: Specify dates with phrases like{' '} - tomorrow, next week, Monday, or{' '} - August 1st at 12pm. -
  • -
  • - Frequency: Set recurring tasks with terms - like daily, weekly, monthly,{' '} - yearly, or patterns such as{' '} - every Tuesday and Thursday. -
  • -
    - - } - /> -
    + +
  • + Priority:For highest priority any of the + following keyword P1, Urgent,{' '} + Important, or ASAP. For lower + priorities, use P2, P3, or P4. +
  • +
  • + Due date: Specify dates with phrases + like tomorrow, next week,{' '} + Monday, or August 1st at 12pm. +
  • +
  • + Frequency: Set recurring tasks with + terms like daily, weekly,{' '} + monthly, yearly, or patterns such as{' '} + every Tuesday and Thursday. +
  • +
    + + } + /> +
    - { - setTaskText(text) - if (!text) setTaskTitle('') - }} - customRenderer={renderedParts} - onEnterPressed={handleEnterPressed} - suggestions={{ - '#': { - value: 'id', - display: 'name', - options: userLabels ? userLabels : [], - }, - '!': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: 'P1' }, - { id: '2', name: 'P2' }, - { id: '3', name: 'P3' }, - { id: '4', name: 'P4' }, - ], - }, - '@': { - value: 'userId', - display: 'displayName', - options: [ - { userId: 'anyone', displayName: 'Anyone' }, - ...(circleMembers?.res || []), - ], - }, - '*': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: '1 point' }, - { id: '5', name: '5 points' }, - { id: '10', name: '10 points' }, - { id: '25', name: '25 points' }, - { id: '50', name: '50 points' }, - { id: '100', name: '100 points' }, - ], - }, - }} - /> -
    - - { - setDueDate(null) - setDueDateOnly(null) - setDueTime(null) - setUseCustomTime(false) - }} - /> - setFrequency(null)} - /> - setPriority(0)} - emptyDisplay={pickerEmptyDisplay} - priorityColors={priorityColors} - priorityLabels={priorityLabels} - /> - { - if (!userId) { - setAssignees([]) - } else { - setAssignees([{ userId }]) - } - }} - onClear={() => setAssignees([])} - currentUserId={userProfile?.id} - members={circleMembers?.res || []} - /> - - setLabelsV2([])} - labels={userLabels || []} - /> - setAttachments([])} - emptyDisplay={pickerEmptyDisplay} - entityType='chore_attachment' - /> - setNotificationMetadata({ templates: [] })} - emptyDisplay={pickerEmptyDisplay} - /> - - - - {!hasDescription && ( - - )} - - - {!hasSubTasks && ( - - )} - - - {hasDescription && ( - - Description: -
    - setShowScan(true)} + onPhotoSelected={dataUrl => { + setPendingPhotoUrl(dataUrl) + setShowScan(true) + }} + placeholder='Type your task...' + onChange={text => { + setTaskText(text) + if (!text) setTaskTitle('') + }} + customRenderer={renderedParts} + onEnterPressed={handleEnterPressed} + suggestions={{ + '#': { + value: 'id', + display: 'name', + options: userLabels ? userLabels : [], + }, + '!': { + value: 'id', + display: 'name', + options: [ + { id: '1', name: 'P1' }, + { id: '2', name: 'P2' }, + { id: '3', name: 'P3' }, + { id: '4', name: 'P4' }, + ], + }, + '@': { + value: 'userId', + display: 'displayName', + options: [ + { userId: 'anyone', displayName: 'Anyone' }, + ...(circleMembers?.res || []), + ], + }, + '*': { + value: 'id', + display: 'name', + options: [ + { id: '1', name: '1 point' }, + { id: '5', name: '5 points' }, + { id: '10', name: '10 points' }, + { id: '25', name: '25 points' }, + { id: '50', name: '50 points' }, + { id: '100', name: '100 points' }, + ], + }, + }} /> -
    -
    +
    + + + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + setLabelsV2([])} + labels={userLabels || []} + /> + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment' + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + + + + {!hasDescription && ( + + )} + {!hasSubTasks && ( + + )} + + + {hasDescription && ( + + Description: +
    + +
    +
    + )} + {hasSubTasks && ( + + Subtasks: + + + )} + )} - {hasSubTasks && ( - - Subtasks: - - + + {showScan && ( + { + setShowScan(false) + setPendingPhotoUrl(null) + }} + /> )} - setPhotoModalOpen(false)} - onTaskExtracted={handleTaskExtracted} - /> ) } diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx new file mode 100644 index 0000000..3ef1c1d --- /dev/null +++ b/src/views/components/ScanToTask/ScanPanel.jsx @@ -0,0 +1,320 @@ +import { + CameraAlt, + DocumentScanner, + PhotoCamera, + Replay, + WarningAmber, +} from '@mui/icons-material' +import { + Box, + Button, + CircularProgress, + LinearProgress, + Typography, +} from '@mui/joy' +import { useEffect } from 'react' +import { useScanToTask } from './useScanToTask' + +/** + * Inline scan-to-task panel. Mounts inside AddTaskModal — no second modal. + * + * Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose] + * → error [retake or cancel] + */ +const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => { + const { + isNativeScanner, + phase, + capturedImage, + ocrProgress, + taskResult, + errorMsg, + cameraAvailable, + videoRef, + canvasRef, + fileInputRef, + startCamera, + stopCamera, + capture, + processImage, + setCapturedImage, + handleFileSelect, + handleNativeScan, + retake, + activate, + reset, + } = useScanToTask() + + // Start/stop based on open state + useEffect(() => { + if (open) { + if (initialImageUrl) { + setCapturedImage(initialImageUrl) + processImage(initialImageUrl, 'browser') + } else if (isNativeScanner) { + handleNativeScan().then(result => { + if (result?.cancelled) onClose() + }) + } else { + activate() + } + } else { + reset() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // Start/stop web camera based on capture phase + useEffect(() => { + if (phase === 'capture' && !isNativeScanner) { + startCamera() + } + if (phase !== 'capture') { + stopCamera() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, isNativeScanner]) + + // Auto-close and populate when done + useEffect(() => { + if (phase === 'done' && taskResult) { + onTaskExtracted(taskResult) + onClose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, taskResult]) + + if (!open) return null + + const isProcessing = phase === 'processing' + + return ( + + {/* ── Capture phase ── */} + {phase === 'capture' && ( + <> + + {isNativeScanner && ( + + + + Tap "Scan Document" to open the scanner + + + )} + + {!isNativeScanner && cameraAvailable && ( + + + + + + + + + {isNativeScanner ? ( + + ) : ( + cameraAvailable && ( + + ) + )} + + + + )} + + {/* ── Processing phase ── */} + {isProcessing && ( + + {capturedImage && ( + + Processing + + + + + )} + + + {ocrProgress > 0 && ocrProgress < 100 + ? `Reading text… ${ocrProgress}%` + : ocrProgress >= 100 + ? 'Identifying task with AI…' + : 'Starting…'} + + + {ocrProgress > 0 && ocrProgress < 100 && ( + + )} + + )} + + {/* ── Error phase ── */} + {phase === 'error' && ( + + {capturedImage && ( + Failed scan + )} + + + {errorMsg} + + + + + + + )} + + + + ) +} + +export default ScanPanel diff --git a/src/views/components/ScanToTask/useScanToTask.js b/src/views/components/ScanToTask/useScanToTask.js new file mode 100644 index 0000000..a2178bb --- /dev/null +++ b/src/views/components/ScanToTask/useScanToTask.js @@ -0,0 +1,250 @@ +import { useCallback, useRef, useState } from 'react' +import { useDocumentScanner } from '../../../hooks/useDocumentScanner' +import { localAIService } from '../../../service/LocalAIService' + +const SYSTEM_PROMPT = `You are helping create tasks for a household task management app. + +Given OCR text extracted from a photo, identify the most useful task a person should add to their task list. + +The task title should always start with an action verb when possible. + +Examples: + +Bill -> "Pay water bill" +Appointment -> "Attend eye doctor appointment" +Invitation -> "RSVP for wedding" +Renewal Notice -> "Renew vehicle registration" +Package Notice -> "Pick up package" +School Form -> "Complete school permission form" + +Action Priority Rules: + +1. Payments and bills +2. Deadlines and renewals +3. Appointments +4. Required forms +5. Informational actions (view, read, review) + +Rules: + +Generate at most one task. +Focus on the most important action. +Extract due dates and deadlines. +Use appointment dates as due dates when appropriate. +Do not invent information. +If the content contains no actionable item, return null values. +Include any important ID or URL or instructions in the description. +Titles must be specific and useful at a glance. +Include the organization, provider, event, or subject when available. +Avoid generic document names. +Return valid JSON only. + +Output: + +{ + "taskName": string | null, + "description": string | null, + "dueDate": string | null, + "confidence": number +}` + +async function runNativeOCR(imageSource) { + const { Ocr } = await import('@jcesarmobile/capacitor-ocr') + const image = imageSource.includes('/_capacitor_file_/') + ? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '') + : imageSource + const result = await Ocr.process({ image }) + return result.results.map(r => r.text).join('\n').trim() +} + +async function runBrowserOCR(imageSource, onProgress) { + const { createWorker } = await import('tesseract.js') + const worker = await createWorker('eng', 1, { + logger: m => { + if (m.status === 'recognizing text' && onProgress) { + onProgress(Math.round(m.progress * 100)) + } + }, + }) + const { data } = await worker.recognize(imageSource) + await worker.terminate() + return data.text?.trim() || '' +} + +async function extractTaskFromOCR(ocrText) { + const messages = [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: `OCR Text:\n${ocrText}` }, + ] + const result = await localAIService.plainChat(messages) + if (!result) return null + const jsonMatch = result.match(/\{[\s\S]*\}/) + if (!jsonMatch) return null + try { + return JSON.parse(jsonMatch[0]) + } catch { + return null + } +} + +// phases: idle | capture | processing | done | error +export function useScanToTask() { + const { isNativeScanner, scanDocument } = useDocumentScanner() + const videoRef = useRef(null) + const canvasRef = useRef(null) + const streamRef = useRef(null) + const fileInputRef = useRef(null) + + const [phase, setPhase] = useState('idle') + const [capturedImage, setCapturedImage] = useState(null) + const [ocrProgress, setOcrProgress] = useState(0) + const [taskResult, setTaskResult] = useState(null) + const [errorMsg, setErrorMsg] = useState('') + const [cameraAvailable, setCameraAvailable] = useState(true) + + const startCamera = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + }) + streamRef.current = stream + if (videoRef.current) { + videoRef.current.srcObject = stream + } + setCameraAvailable(true) + } catch { + setCameraAvailable(false) + } + }, []) + + const stopCamera = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(t => t.stop()) + streamRef.current = null + } + }, []) + + const processImage = useCallback(async (imageData, method = 'browser') => { + setPhase('processing') + setOcrProgress(0) + setErrorMsg('') + + try { + let text + if (method === 'native') { + text = await runNativeOCR(imageData) + } else { + text = await runBrowserOCR(imageData, pct => setOcrProgress(pct)) + } + + if (!text) { + setErrorMsg('No text found in image. Try a clearer photo.') + setPhase('error') + return + } + + const task = await extractTaskFromOCR(text) + if (!task || !task.taskName) { + setErrorMsg('Could not identify a task. Try a different photo.') + setPhase('error') + return + } + + setTaskResult(task) + setPhase('done') + } catch (e) { + setErrorMsg(e.message || 'Processing failed.') + setPhase('error') + } + }, []) + + const capture = useCallback(() => { + if (!videoRef.current || !canvasRef.current) return + const video = videoRef.current + const canvas = canvasRef.current + canvas.width = video.videoWidth + canvas.height = video.videoHeight + canvas.getContext('2d').drawImage(video, 0, 0) + const dataUrl = canvas.toDataURL('image/jpeg', 0.9) + setCapturedImage(dataUrl) + stopCamera() + processImage(dataUrl, 'browser') + }, [stopCamera, processImage]) + + const handleFileSelect = useCallback( + e => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = ev => { + const dataUrl = ev.target.result + setCapturedImage(dataUrl) + stopCamera() + processImage(dataUrl, 'browser') + } + reader.readAsDataURL(file) + }, + [stopCamera, processImage], + ) + + const handleNativeScan = useCallback(async () => { + const { image, cancelled, error } = await scanDocument() + if (cancelled) return { cancelled: true } + if (error || !image) { + setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.') + setPhase('error') + return { cancelled: false } + } + setCapturedImage(image) + processImage(image, 'native') + return { cancelled: false } + }, [scanDocument, processImage]) + + const retake = useCallback(() => { + setCapturedImage(null) + setTaskResult(null) + setErrorMsg('') + setOcrProgress(0) + setPhase('capture') + }, []) + + const activate = useCallback(() => { + setCapturedImage(null) + setTaskResult(null) + setErrorMsg('') + setOcrProgress(0) + setPhase('capture') + }, []) + + const reset = useCallback(() => { + stopCamera() + setCapturedImage(null) + setTaskResult(null) + setErrorMsg('') + setOcrProgress(0) + setPhase('idle') + }, [stopCamera]) + + return { + isNativeScanner, + phase, + capturedImage, + setCapturedImage, + ocrProgress, + taskResult, + errorMsg, + cameraAvailable, + videoRef, + canvasRef, + fileInputRef, + startCamera, + stopCamera, + capture, + processImage, + handleFileSelect, + handleNativeScan, + retake, + activate, + reset, + } +} diff --git a/src/views/components/SmartTaskTitleInput.css b/src/views/components/SmartTaskTitleInput.css index c2bb002..4e87af5 100644 --- a/src/views/components/SmartTaskTitleInput.css +++ b/src/views/components/SmartTaskTitleInput.css @@ -31,7 +31,6 @@ font-size: 1.2em; line-height: 1.2em; font-family: inherit; - caret-color: var(--highlight-date-color); } .highlight-date { @@ -61,8 +60,18 @@ .task-input { position: relative; width: 100%; - border-radius: 8px; + border-radius: 10px; box-sizing: border-box; - border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd); + border: none; + background-color: var(--joy-palette-neutral-softBg, #f0f4f8); overflow: auto; + transition: box-shadow 0.15s ease, background-color 0.15s ease; +} + +.task-input:focus-within { + box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15)); +} + +.task-input:hover:not(:focus-within) { + background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee); } diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index afc873e..9a66b32 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -1,4 +1,5 @@ -import { useColorScheme } from '@mui/joy' +import { CameraEnhance, PhotoFilter } from '@mui/icons-material' +import { IconButton, Tooltip, useColorScheme } from '@mui/joy' import { useEffect, useRef, useState } from 'react' import AutocompleteDropdown from '../TestView/AutocompleteDropdown' import './SmartTaskTitleInput.css' @@ -52,9 +53,13 @@ const SmartTaskTitleInput = ({ suggestions, onEnterPressed, customRenderer, + isNativeScanner, + onScanClick, + onPhotoSelected, }) => { const { mode, setMode } = useColorScheme() const titleInputRef = useRef(null) + const photoInputRef = useRef(null) const [cursorPosition, setCursorPosition] = useState(value?.length) const dropdownRef = useRef(null) const [lastWord, setLastWord] = useState('') @@ -181,9 +186,26 @@ const SmartTaskTitleInput = ({ } } + const handlePhotoInputChange = e => { + const file = e.target.files?.[0] + if (!file || !onPhotoSelected) return + const reader = new FileReader() + reader.onload = ev => onPhotoSelected(ev.target.result) + reader.readAsDataURL(file) + e.target.value = '' + } + + const showNativeButtons = isNativeScanner && !value + const MIC_BUTTON_WIDTH = + showNativeButtons && onPhotoSelected && onScanClick + ? '5rem' + : showNativeButtons + ? '2.5rem' + : '0rem' + return (
    -
    +