Ability to have open OCR from the field
This commit is contained in:
@@ -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 }) => {
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
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.
|
||||
</Typography>
|
||||
{!showScan && (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
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.
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of the
|
||||
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower priorities,
|
||||
use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases like{' '}
|
||||
<em>tomorrow</em>, <em>next week</em>, <em>Monday</em>, or{' '}
|
||||
<em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with terms
|
||||
like <em>daily</em>, <em>weekly</em>, <em>monthly</em>,{' '}
|
||||
<em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of the
|
||||
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower
|
||||
priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases
|
||||
like <em>tomorrow</em>, <em>next week</em>,{' '}
|
||||
<em>Monday</em>, or <em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with
|
||||
terms like <em>daily</em>, <em>weekly</em>,{' '}
|
||||
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
|
||||
// scrollable horizontally but hide the scrollbar:
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
// if not mobile then go to next line if not enough space( show chip on next line):
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasDescription(true)
|
||||
// Focus will be handled by the useEffect hook
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
startDecorator={<CameraAlt />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => setPhotoModalOpen(true)}
|
||||
>
|
||||
Scan Photo
|
||||
</Button>
|
||||
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasSubTasks(true)
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
isNativeScanner={isNativeScanner}
|
||||
onScanClick={() => 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' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasDescription(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasSubTasks(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showScan && (
|
||||
<ScanPanel
|
||||
open
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setPendingPhotoUrl(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
<PhotoTaskModal
|
||||
open={photoModalOpen}
|
||||
onClose={() => setPhotoModalOpen(false)}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
320
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
320
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
}}
|
||||
>
|
||||
{/* ── Capture phase ── */}
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
maxHeight: 300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'neutral.900',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isNativeScanner && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<DocumentScanner
|
||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && cameraAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 300,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && !cameraAvailable && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<CameraAlt
|
||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Camera not available — use Upload instead
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner fontSize='small' />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt fontSize='small' />}
|
||||
onClick={capture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Processing phase ── */}
|
||||
{isProcessing && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
{capturedImage && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 180,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
{ocrProgress > 0 && ocrProgress < 100
|
||||
? `Reading text… ${ocrProgress}%`
|
||||
: ocrProgress >= 100
|
||||
? 'Identifying task with AI…'
|
||||
: 'Starting…'}
|
||||
</Typography>
|
||||
|
||||
{ocrProgress > 0 && ocrProgress < 100 && (
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={ocrProgress}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Error phase ── */}
|
||||
{phase === 'error' && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Failed scan'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
borderRadius: 8,
|
||||
maxHeight: 160,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.4,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay fontSize='small' />}
|
||||
onClick={retake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScanPanel
|
||||
250
src/views/components/ScanToTask/useScanToTask.js
Normal file
250
src/views/components/ScanToTask/useScanToTask.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className='task-input' style={{ minHeight: '2.4em' }}>
|
||||
<div className='task-input' style={{ minHeight: '2.8em' }}>
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
autoFocus={autoFocus}
|
||||
@@ -196,13 +218,12 @@ const SmartTaskTitleInput = ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
|
||||
height: '100%',
|
||||
// opacity: 100,
|
||||
zIndex: 1,
|
||||
resize: 'none',
|
||||
overflow: 'hidden',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
@@ -212,6 +233,8 @@ const SmartTaskTitleInput = ({
|
||||
backgroundColor: 'transparent',
|
||||
color: 'transparent',
|
||||
caretColor: mode === 'dark' ? '#fff' : '#000',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -221,7 +244,7 @@ const SmartTaskTitleInput = ({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: '1.2em',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'inherit',
|
||||
@@ -231,7 +254,12 @@ const SmartTaskTitleInput = ({
|
||||
onClick={handleDisplayClick}
|
||||
>
|
||||
{placeholder && !value && (
|
||||
<span className='pointer-events-none text-gray-400'>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--joy-palette-text-tertiary, #9fa6ad)',
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
@@ -241,6 +269,55 @@ const SmartTaskTitleInput = ({
|
||||
{/* Zero-width space to maintain consistent height */}
|
||||
​
|
||||
</div>
|
||||
{showNativeButtons && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '0.3rem',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{onPhotoSelected && (
|
||||
<>
|
||||
<Tooltip title='Select photo' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<PhotoFilter fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePhotoInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onScanClick && (
|
||||
<Tooltip title='Scan to create task' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onScanClick}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<CameraEnhance fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<AutocompleteDropdown
|
||||
|
||||
Reference in New Issue
Block a user