Add Initial Voice Input
This commit is contained in:
121
src/views/components/VoiceToTask/VoicePanel.css
Normal file
121
src/views/components/VoiceToTask/VoicePanel.css
Normal file
@@ -0,0 +1,121 @@
|
||||
.voice-mic-btn {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--joy-palette-primary-solidBg, #0b6bcb);
|
||||
color: #fff;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.voice-mic-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.voice-mic-btn.listening {
|
||||
background: var(--joy-palette-danger-solidBg, #c41c1c);
|
||||
box-shadow: 0 4px 18px rgba(196, 28, 28, 0.35);
|
||||
}
|
||||
|
||||
.voice-pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.voice-mic-btn.listening .voice-pulse-ring {
|
||||
opacity: 1;
|
||||
animation: voice-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.voice-mic-btn.listening .voice-pulse-ring:nth-child(2) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(196, 28, 28, 0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 26px rgba(196, 28, 28, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Faux equalizer shown while listening */
|
||||
.voice-eq {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.voice-eq span {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--joy-palette-danger-solidBg, #c41c1c);
|
||||
animation: voice-eq-wave 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-eq span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.voice-eq span:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.voice-eq span:nth-child(3) {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
.voice-eq span:nth-child(4) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.voice-eq span:nth-child(5) {
|
||||
animation-delay: 0.26s;
|
||||
}
|
||||
|
||||
@keyframes voice-eq-wave {
|
||||
0%,
|
||||
100% {
|
||||
height: 6px;
|
||||
}
|
||||
50% {
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Committed task cards slide in as segments are captured */
|
||||
.voice-task-card {
|
||||
animation: voice-card-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes voice-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.voice-mic-btn.listening .voice-pulse-ring,
|
||||
.voice-eq span,
|
||||
.voice-task-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
493
src/views/components/VoiceToTask/VoicePanel.jsx
Normal file
493
src/views/components/VoiceToTask/VoicePanel.jsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Close,
|
||||
Flag,
|
||||
GraphicEq,
|
||||
Lock,
|
||||
Mic,
|
||||
Person,
|
||||
Repeat,
|
||||
Sell,
|
||||
Toll,
|
||||
WarningAmber,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { parseVoiceTask } from './parseVoiceTask'
|
||||
import { useVoiceToTask } from './useVoiceToTask'
|
||||
import './VoicePanel.css'
|
||||
|
||||
const HIGHLIGHT_CLASS = {
|
||||
repeat: 'highlight-repeat',
|
||||
priority: 'highlight-priority',
|
||||
points: 'highlight-points',
|
||||
assignee: 'highlight-assignee',
|
||||
label: 'highlight-label',
|
||||
dueDate: 'highlight-date',
|
||||
}
|
||||
|
||||
const renderTranscript = (text, highlights) => {
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
for (const h of highlights) {
|
||||
if (h.start > lastIndex) parts.push(text.substring(lastIndex, h.start))
|
||||
parts.push(
|
||||
<span
|
||||
key={h.start}
|
||||
className={HIGHLIGHT_CLASS[h.type]}
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
>
|
||||
{text.substring(h.start, h.end)}
|
||||
</span>,
|
||||
)
|
||||
lastIndex = h.end
|
||||
}
|
||||
if (lastIndex < text.length) parts.push(text.substring(lastIndex))
|
||||
return parts
|
||||
}
|
||||
|
||||
const formatDue = dueDate => {
|
||||
const m = moment(dueDate)
|
||||
return m.format('HH:mm') === '23:59'
|
||||
? m.format('MMM D')
|
||||
: m.format('MMM D, h:mm A')
|
||||
}
|
||||
|
||||
const buildChips = (parsed, { members, currentUserId }) => {
|
||||
const chips = []
|
||||
if (parsed.dueDate) {
|
||||
chips.push({
|
||||
key: 'due',
|
||||
color: 'warning',
|
||||
icon: <CalendarMonth sx={{ fontSize: 12 }} />,
|
||||
label: formatDue(parsed.dueDate),
|
||||
})
|
||||
}
|
||||
if (parsed.frequencyName) {
|
||||
chips.push({
|
||||
key: 'repeat',
|
||||
color: 'success',
|
||||
icon: <Repeat sx={{ fontSize: 12 }} />,
|
||||
label: parsed.frequencyName,
|
||||
})
|
||||
}
|
||||
if (parsed.priority > 0) {
|
||||
chips.push({
|
||||
key: 'priority',
|
||||
color: 'danger',
|
||||
icon: <Flag sx={{ fontSize: 12 }} />,
|
||||
label: `P${parsed.priority}`,
|
||||
})
|
||||
}
|
||||
if (parsed.points != null) {
|
||||
chips.push({
|
||||
key: 'points',
|
||||
color: 'primary',
|
||||
icon: <Toll sx={{ fontSize: 12 }} />,
|
||||
label: `${parsed.points} pts`,
|
||||
})
|
||||
}
|
||||
parsed.labelNames.forEach(name => {
|
||||
chips.push({
|
||||
key: `label-${name}`,
|
||||
color: 'primary',
|
||||
icon: <Sell sx={{ fontSize: 12 }} />,
|
||||
label: name,
|
||||
})
|
||||
})
|
||||
if (parsed.isAnyone) {
|
||||
chips.push({
|
||||
key: 'assignee',
|
||||
color: 'neutral',
|
||||
icon: <Person sx={{ fontSize: 12 }} />,
|
||||
label: 'Anyone',
|
||||
})
|
||||
} else if (
|
||||
parsed.assignees.length > 0 &&
|
||||
parsed.assignees[0].userId !== currentUserId
|
||||
) {
|
||||
const member = members.find(m => m.userId === parsed.assignees[0].userId)
|
||||
if (member) {
|
||||
chips.push({
|
||||
key: 'assignee',
|
||||
color: 'neutral',
|
||||
icon: <Person sx={{ fontSize: 12 }} />,
|
||||
label: member.displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(segment.text)
|
||||
|
||||
const parsed = useMemo(
|
||||
() => parseVoiceTask(segment.text, parseCtx),
|
||||
[segment.text, parseCtx],
|
||||
)
|
||||
const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx])
|
||||
|
||||
const commitEdit = () => {
|
||||
setEditing(false)
|
||||
if (draft.trim() !== segment.text) onUpdate(draft)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='voice-task-card'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1.25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{editing ? (
|
||||
<Input
|
||||
size='sm'
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitEdit()
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(segment.text)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
onBlur={commitEdit}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ flex: 1, cursor: 'text', wordBreak: 'break-word' }}
|
||||
onClick={() => {
|
||||
setDraft(segment.text)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{parsed.title || segment.text}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onRemove}
|
||||
sx={{ '--IconButton-size': '28px' }}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{chips.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{chips.map(chip => (
|
||||
<Chip
|
||||
key={chip.key}
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={chip.color}
|
||||
startDecorator={chip.icon}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||
*
|
||||
* Hold the mic to speak, or tap once for hands-free. Pauses and spoken
|
||||
* separators ("also") split the transcript into task cards. A single captured
|
||||
* task lands in the smart input for review; multiple tasks are created
|
||||
* directly from the review list.
|
||||
*/
|
||||
const VoicePanel = ({
|
||||
open,
|
||||
userLabels = [],
|
||||
members = [],
|
||||
userProfile,
|
||||
onClose,
|
||||
onUseSingle,
|
||||
onCreateMany,
|
||||
}) => {
|
||||
const {
|
||||
phase,
|
||||
isLocked,
|
||||
partialText,
|
||||
segments,
|
||||
micPressDown,
|
||||
micPressUp,
|
||||
startListening,
|
||||
removeSegment,
|
||||
updateSegment,
|
||||
reset,
|
||||
isNative,
|
||||
} = useVoiceToTask({ members })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const parseCtx = useMemo(
|
||||
() => ({ userLabels, members, currentUserId: userProfile?.id }),
|
||||
[userLabels, members, userProfile?.id],
|
||||
)
|
||||
|
||||
const partialParsed = useMemo(
|
||||
() => (partialText ? parseVoiceTask(partialText, parseCtx) : null),
|
||||
[partialText, parseCtx],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isListening = phase === 'listening'
|
||||
const showActions = segments.length > 0 && !isListening && !creating
|
||||
|
||||
const handleCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCreateAll = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx)))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const micCaption = isListening
|
||||
? isLocked
|
||||
? 'Listening — tap to stop'
|
||||
: 'Release to finish · quick tap locks hands-free'
|
||||
: segments.length > 0
|
||||
? 'Hold to add another task'
|
||||
: 'Hold to speak · quick tap for hands-free'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
pt: 1.25,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<GraphicEq color='primary' fontSize='small' />
|
||||
<Typography level='title-sm'>Speak your tasks</Typography>
|
||||
{isNative && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='success'
|
||||
startDecorator={<Lock sx={{ fontSize: 12 }} />}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
On-device
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Permission denied ── */}
|
||||
{phase === 'denied' && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1.5 }}
|
||||
>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>
|
||||
Microphone access is needed for voice capture. Enable it in your
|
||||
device settings and try again.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={startListening}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Captured task cards ── */}
|
||||
{segments.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
pt: 1.25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{segments.map(segment => (
|
||||
<TaskPreviewCard
|
||||
key={segment.id}
|
||||
segment={segment}
|
||||
parseCtx={parseCtx}
|
||||
onRemove={() => removeSegment(segment.id)}
|
||||
onUpdate={text => updateSegment(segment.id, text)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Live transcript ── */}
|
||||
{isListening && (
|
||||
<Box sx={{ px: 1.5, pt: 1.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
borderRadius: 'md',
|
||||
border: '1px dashed',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
{partialText ? (
|
||||
<Typography level='body-md' sx={{ wordBreak: 'break-word' }}>
|
||||
{renderTranscript(partialText, partialParsed?.highlights || [])}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography level='body-sm' sx={{ opacity: 0.5 }}>
|
||||
Listening…
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Mic stage ── */}
|
||||
{phase !== 'denied' && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
py: 2,
|
||||
gap: 1.25,
|
||||
}}
|
||||
>
|
||||
<div className='voice-eq' style={{ opacity: isListening ? 1 : 0 }}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={isListening ? 'Stop listening' : 'Start voice capture'}
|
||||
className={`voice-mic-btn${isListening ? ' listening' : ''}`}
|
||||
onPointerDown={e => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
micPressDown()
|
||||
}}
|
||||
onPointerUp={micPressUp}
|
||||
onPointerCancel={micPressUp}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<span className='voice-pulse-ring' />
|
||||
<span className='voice-pulse-ring' />
|
||||
<Mic sx={{ fontSize: 32 }} />
|
||||
</button>
|
||||
<Typography level='body-xs' sx={{ opacity: 0.7 }}>
|
||||
{micCaption}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ opacity: 0.5, px: 2, textAlign: 'center' }}
|
||||
>
|
||||
Pause or say “also” between tasks · say
|
||||
“scratch that” to remove the last one
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
{creating && (
|
||||
<Button size='sm' variant='solid' color='primary' loading>
|
||||
Creating…
|
||||
</Button>
|
||||
)}
|
||||
{showActions &&
|
||||
(segments.length === 1 ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => onUseSingle(segments[0].text)}
|
||||
>
|
||||
Use Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleCreateAll}
|
||||
>
|
||||
Create {segments.length} Tasks
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoicePanel
|
||||
201
src/views/components/VoiceToTask/parseVoiceTask.js
Normal file
201
src/views/components/VoiceToTask/parseVoiceTask.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { isPlusAccount } from '../../../utils/Helpers'
|
||||
import { generateUUID } from '../../../utils/UUID'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseDueDate,
|
||||
parseLabels,
|
||||
parsePoints,
|
||||
parsePriority,
|
||||
parseRepeatV2,
|
||||
} from '../CustomParsers'
|
||||
|
||||
// Pure equivalent of AddTaskModal.processText — parses one sentence into a
|
||||
// structured task (no state setters), preserving the same parser order and
|
||||
// sequential-cleanup behavior so voice and typed input stay consistent.
|
||||
|
||||
const mapMembersForParsing = members =>
|
||||
members.map(member => ({
|
||||
userId: member.userId,
|
||||
username:
|
||||
member.username || member.displayName?.toLowerCase().replace(/\s+/g, ''),
|
||||
displayName: member.displayName,
|
||||
name: member.displayName,
|
||||
id: member.userId,
|
||||
}))
|
||||
|
||||
// Merge overlapping highlight ranges, higher parser priority wins — same
|
||||
// resolution rules as AddTaskModal.renderHighlightedSentence.
|
||||
const resolveHighlights = ({
|
||||
repeat,
|
||||
priority,
|
||||
points,
|
||||
assignees,
|
||||
labels,
|
||||
dueDate,
|
||||
}) => {
|
||||
const all = []
|
||||
repeat?.forEach(h => all.push({ ...h, type: 'repeat', rank: 60 }))
|
||||
priority?.forEach(h => all.push({ ...h, type: 'priority', rank: 50 }))
|
||||
points?.forEach(h => all.push({ ...h, type: 'points', rank: 45 }))
|
||||
assignees?.forEach(h => all.push({ ...h, type: 'assignee', rank: 40 }))
|
||||
labels?.forEach(h => all.push({ ...h, type: 'label', rank: 30 }))
|
||||
if (dueDate) all.push({ ...dueDate, type: 'dueDate', rank: 20 })
|
||||
|
||||
all.sort((a, b) => a.start - b.start)
|
||||
const resolved = []
|
||||
for (const current of all) {
|
||||
const previous = resolved[resolved.length - 1]
|
||||
if (previous && current.start < previous.end) {
|
||||
if (current.rank > previous.rank) {
|
||||
resolved.pop()
|
||||
resolved.push(current)
|
||||
}
|
||||
} else {
|
||||
resolved.push(current)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export const parseVoiceTask = (
|
||||
sentence,
|
||||
{ userLabels = [], members = [], currentUserId = null } = {},
|
||||
) => {
|
||||
const assigneesForParsing = mapMembersForParsing(members)
|
||||
|
||||
const priority = parsePriority(sentence)
|
||||
const points = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels)
|
||||
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
|
||||
const repeat = parseRepeatV2(sentence)
|
||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||
|
||||
// Sequential cleanup — identical chain to AddTaskModal.processText
|
||||
let cleaned = sentence
|
||||
if (priority.result) cleaned = priority.cleanedSentence
|
||||
if (points.result) {
|
||||
const reparse = parsePoints(cleaned)
|
||||
if (reparse.result) cleaned = reparse.cleanedSentence
|
||||
}
|
||||
if (labels.result) {
|
||||
const reparse = parseLabels(cleaned, userLabels)
|
||||
if (reparse.result) cleaned = reparse.cleanedSentence
|
||||
}
|
||||
if (assigneesResult.result) {
|
||||
const reparse = parseAssignees(cleaned, assigneesForParsing)
|
||||
if (reparse.result) cleaned = reparse.cleanedSentence
|
||||
}
|
||||
if (repeat.result) {
|
||||
const reparse = parseRepeatV2(cleaned)
|
||||
if (reparse.result) cleaned = reparse.cleanedSentence
|
||||
}
|
||||
if (dueDateParsed.result) {
|
||||
const reparse = parseDueDate(cleaned, chrono)
|
||||
if (reparse.result) cleaned = reparse.cleanedSentence
|
||||
}
|
||||
|
||||
let dueDate = null
|
||||
if (dueDateParsed.result) {
|
||||
dueDate = moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')
|
||||
} else if (repeat.dueDate) {
|
||||
dueDate = moment(repeat.dueDate).format('YYYY-MM-DDTHH:mm:ss')
|
||||
}
|
||||
|
||||
let assignees = []
|
||||
const isAnyone = !!assigneesResult.isAnyone
|
||||
if (!isAnyone) {
|
||||
if (assigneesResult.result?.length > 0) {
|
||||
assignees = assigneesResult.result.map(a => ({ userId: a.userId }))
|
||||
} else if (currentUserId) {
|
||||
assignees = [{ userId: currentUserId }]
|
||||
}
|
||||
}
|
||||
|
||||
const labelIds = (labels.result || [])
|
||||
.filter(label => label.id)
|
||||
.map(label => label.id)
|
||||
|
||||
return {
|
||||
raw: sentence,
|
||||
title: cleaned.replace(/\s+/g, ' ').trim(),
|
||||
priority: priority.result ? parseInt(priority.result, 10) : 0,
|
||||
points: points.result ?? null,
|
||||
labelIds,
|
||||
labelNames: (labels.result || []).map(label => label.name),
|
||||
assignees,
|
||||
isAnyone,
|
||||
frequency: repeat.result,
|
||||
frequencyName: repeat.name,
|
||||
dueDate,
|
||||
highlights: resolveHighlights({
|
||||
repeat: repeat.highlight,
|
||||
priority: priority.highlight,
|
||||
points: points.highlight,
|
||||
assignees: assigneesResult.highlight,
|
||||
labels: labels.highlight,
|
||||
dueDate: dueDateParsed.result ? dueDateParsed.highlight[0] : null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the same chore payload shape AddTaskModal.createChore submits.
|
||||
export const buildChorePayload = (
|
||||
parsed,
|
||||
{ userProfile, projectId, notificationTemplates },
|
||||
) => {
|
||||
let finalAssignees = parsed.assignees
|
||||
let finalAssignedTo = null
|
||||
let finalAssignStrategy = 'keep_last_assigned'
|
||||
|
||||
if (parsed.isAnyone) {
|
||||
finalAssignees = []
|
||||
finalAssignStrategy = 'no_assignee'
|
||||
} else if (finalAssignees.length === 0) {
|
||||
finalAssignees = [{ userId: userProfile?.id }]
|
||||
finalAssignedTo = userProfile?.id
|
||||
} else {
|
||||
finalAssignedTo = finalAssignees[0].userId
|
||||
}
|
||||
|
||||
const chore = {
|
||||
name: parsed.title,
|
||||
description: null,
|
||||
assignees: finalAssignees,
|
||||
dueDate: parsed.dueDate ? new Date(parsed.dueDate).toISOString() : null,
|
||||
assignedTo: finalAssignedTo,
|
||||
assignStrategy: finalAssignStrategy,
|
||||
isRolling: false,
|
||||
labelsV2: parsed.labelIds,
|
||||
priority: parsed.priority || 0,
|
||||
points: parsed.points ?? null,
|
||||
deadlineOffset: null,
|
||||
completionWindow: null,
|
||||
requireApproval: false,
|
||||
isPrivate: false,
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
frequencyMetadata: {},
|
||||
notificationMetadata: {},
|
||||
subTasks: null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: generateUUID(),
|
||||
}
|
||||
|
||||
if (parsed.frequency) {
|
||||
chore.frequencyType = parsed.frequency.frequencyType
|
||||
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
|
||||
chore.frequency = parsed.frequency.frequency
|
||||
if (isPlusAccount(userProfile)) {
|
||||
chore.notification = true
|
||||
chore.notificationMetadata = { templates: notificationTemplates }
|
||||
}
|
||||
}
|
||||
if (!parsed.frequency && parsed.dueDate) {
|
||||
chore.nextDueDate = new Date(parsed.dueDate).toISOString()
|
||||
chore.notificationMetadata = { templates: notificationTemplates }
|
||||
}
|
||||
|
||||
return chore
|
||||
}
|
||||
217
src/views/components/VoiceToTask/useVoiceToTask.js
Normal file
217
src/views/components/VoiceToTask/useVoiceToTask.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { voiceInputService } from '../../../service/VoiceInputService'
|
||||
import { generateUUID } from '../../../utils/UUID'
|
||||
import {
|
||||
applyScratchThat,
|
||||
normalizeSpokenText,
|
||||
splitSpokenSegments,
|
||||
} from './voiceNormalizer'
|
||||
|
||||
// Mic gesture: hold = push-to-talk (release stops), quick tap = hands-free
|
||||
// lock (tap again to stop). In hands-free mode, sustained silence auto-stops
|
||||
// into review so the user is never stuck watching a live mic.
|
||||
|
||||
const TAP_THRESHOLD_MS = 400
|
||||
const HANDS_FREE_SILENCE_STOP_MS = 8000
|
||||
const HANDS_FREE_EMPTY_STOP_MS = 20000
|
||||
|
||||
const haptic = async kind => {
|
||||
try {
|
||||
const { Haptics, ImpactStyle, NotificationType } = await import(
|
||||
'@capacitor/haptics'
|
||||
)
|
||||
if (kind === 'notification') {
|
||||
await Haptics.notification({ type: NotificationType.Success })
|
||||
} else if (kind === 'medium') {
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
} else {
|
||||
await Haptics.impact({ style: ImpactStyle.Light })
|
||||
}
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
|
||||
// phases: idle | listening | review | denied
|
||||
export function useVoiceToTask({ members = [] } = {}) {
|
||||
const [phase, setPhase] = useState('idle')
|
||||
const [isLocked, setIsLocked] = useState(false)
|
||||
const [partialText, setPartialText] = useState('')
|
||||
const [segments, setSegments] = useState([])
|
||||
|
||||
// Kept in sync manually (not via render) so segment commits that happen
|
||||
// inside voiceInputService.stop() are visible immediately afterwards.
|
||||
const segmentsRef = useRef([])
|
||||
const membersRef = useRef(members)
|
||||
membersRef.current = members
|
||||
|
||||
const phaseRef = useRef(phase)
|
||||
phaseRef.current = phase
|
||||
const lockedRef = useRef(isLocked)
|
||||
lockedRef.current = isLocked
|
||||
|
||||
const pressStartedAtRef = useRef(0)
|
||||
const pressStartedListeningRef = useRef(false)
|
||||
const lastActivityRef = useRef(0)
|
||||
const watchdogRef = useRef(null)
|
||||
|
||||
const applySegments = useCallback(next => {
|
||||
segmentsRef.current = next
|
||||
setSegments(next)
|
||||
}, [])
|
||||
|
||||
const commitSegment = useCallback(
|
||||
rawText => {
|
||||
const normalized = normalizeSpokenText(rawText, {
|
||||
members: membersRef.current,
|
||||
})
|
||||
const { text, dropPrevious } = applyScratchThat(normalized)
|
||||
const pieces = splitSpokenSegments(text)
|
||||
if (!dropPrevious && pieces.length === 0) return
|
||||
|
||||
let base = segmentsRef.current
|
||||
if (dropPrevious && base.length > 0) {
|
||||
base = base.slice(0, -1)
|
||||
haptic('medium')
|
||||
}
|
||||
if (pieces.length > 0) haptic('light')
|
||||
applySegments([
|
||||
...base,
|
||||
...pieces.map(piece => ({ id: generateUUID(), text: piece })),
|
||||
])
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const stopListening = useCallback(async () => {
|
||||
if (watchdogRef.current) {
|
||||
clearInterval(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
await voiceInputService.stop()
|
||||
setPartialText('')
|
||||
setIsLocked(false)
|
||||
// stop() commits any buffered partial synchronously through onSegment,
|
||||
// so the ref is up to date by the time we read it
|
||||
setPhase(segmentsRef.current.length > 0 ? 'review' : 'idle')
|
||||
haptic('light')
|
||||
}, [])
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
const permission = await voiceInputService.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
setPhase('denied')
|
||||
return false
|
||||
}
|
||||
lastActivityRef.current = Date.now()
|
||||
await voiceInputService.start({
|
||||
onPartial: text => {
|
||||
lastActivityRef.current = Date.now()
|
||||
setPartialText(
|
||||
normalizeSpokenText(text, { members: membersRef.current }),
|
||||
)
|
||||
},
|
||||
onSegment: commitSegment,
|
||||
onError: () => {
|
||||
setPhase('denied')
|
||||
},
|
||||
onStateChange: () => {},
|
||||
})
|
||||
setPhase('listening')
|
||||
haptic('medium')
|
||||
|
||||
// Hands-free: auto-stop into review after sustained silence
|
||||
watchdogRef.current = setInterval(() => {
|
||||
if (phaseRef.current !== 'listening' || !lockedRef.current) return
|
||||
const idleFor = Date.now() - lastActivityRef.current
|
||||
const limit =
|
||||
segmentsRef.current.length > 0
|
||||
? HANDS_FREE_SILENCE_STOP_MS
|
||||
: HANDS_FREE_EMPTY_STOP_MS
|
||||
if (idleFor > limit) {
|
||||
stopListening()
|
||||
}
|
||||
}, 1000)
|
||||
return true
|
||||
}, [commitSegment, stopListening])
|
||||
|
||||
const micPressDown = useCallback(() => {
|
||||
pressStartedAtRef.current = Date.now()
|
||||
if (phaseRef.current === 'listening') {
|
||||
pressStartedListeningRef.current = false
|
||||
return
|
||||
}
|
||||
pressStartedListeningRef.current = true
|
||||
startListening()
|
||||
}, [startListening])
|
||||
|
||||
const micPressUp = useCallback(() => {
|
||||
const held = Date.now() - pressStartedAtRef.current
|
||||
if (pressStartedListeningRef.current) {
|
||||
if (held < TAP_THRESHOLD_MS) {
|
||||
// Quick tap → hands-free lock
|
||||
setIsLocked(true)
|
||||
} else {
|
||||
// Hold-to-talk → release ends the capture
|
||||
stopListening()
|
||||
}
|
||||
} else if (phaseRef.current === 'listening') {
|
||||
// Tap while already listening (locked mode) → stop
|
||||
stopListening()
|
||||
}
|
||||
pressStartedListeningRef.current = false
|
||||
}, [stopListening])
|
||||
|
||||
const removeSegment = useCallback(
|
||||
id => {
|
||||
applySegments(segmentsRef.current.filter(s => s.id !== id))
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const updateSegment = useCallback(
|
||||
(id, text) => {
|
||||
applySegments(
|
||||
segmentsRef.current
|
||||
.map(s => (s.id === id ? { ...s, text: text.trim() } : s))
|
||||
.filter(s => s.text),
|
||||
)
|
||||
},
|
||||
[applySegments],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
voiceInputService.stop()
|
||||
if (watchdogRef.current) {
|
||||
clearInterval(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
applySegments([])
|
||||
setPartialText('')
|
||||
setIsLocked(false)
|
||||
setPhase('idle')
|
||||
}, [applySegments])
|
||||
|
||||
// Stop the recognizer if the panel unmounts mid-capture
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
voiceInputService.stop()
|
||||
if (watchdogRef.current) clearInterval(watchdogRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase,
|
||||
isLocked,
|
||||
partialText,
|
||||
segments,
|
||||
micPressDown,
|
||||
micPressUp,
|
||||
startListening,
|
||||
stopListening,
|
||||
removeSegment,
|
||||
updateSegment,
|
||||
reset,
|
||||
isNative: voiceInputService.isNative,
|
||||
}
|
||||
}
|
||||
129
src/views/components/VoiceToTask/voiceNormalizer.js
Normal file
129
src/views/components/VoiceToTask/voiceNormalizer.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Deterministic transforms that turn spoken language into the typed syntax
|
||||
// CustomParsers understands. No LLM — instant, predictable, fully offline.
|
||||
//
|
||||
// "label groceries" → "#groceries"
|
||||
// "assign to Sarah" → "@Sarah" (only when Sarah is a circle member)
|
||||
// "worth five points" → "*5"
|
||||
// "p one" / "top priority" → "priority 1" (parsePriority already handles that)
|
||||
|
||||
const FILLER_REGEX = /(?:^|\s)(?:um+|uh+|erm+|hmm+|mmm+)(?=[\s,.!?]|$)[,.]?/gi
|
||||
|
||||
const NUMBER_WORDS = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
fifteen: 15,
|
||||
twenty: 20,
|
||||
'twenty five': 25,
|
||||
'twenty-five': 25,
|
||||
fifty: 50,
|
||||
hundred: 100,
|
||||
'one hundred': 100,
|
||||
}
|
||||
|
||||
const NUMBER_WORD_PATTERN = Object.keys(NUMBER_WORDS)
|
||||
// Longest first so "twenty five" wins over "five"
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.join('|')
|
||||
|
||||
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
export const stripFillers = text =>
|
||||
text.replace(FILLER_REGEX, ' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const normalizePriority = text =>
|
||||
text
|
||||
.replace(/\b(?:top|highest)\s+priority\b/gi, 'priority 1')
|
||||
.replace(
|
||||
/\bp[\s-]?(one|two|three|four|[1-4])\b/gi,
|
||||
(_, n) => `priority ${NUMBER_WORDS[n.toLowerCase()] || n}`,
|
||||
)
|
||||
|
||||
const normalizePoints = text =>
|
||||
text.replace(
|
||||
new RegExp(
|
||||
`\\b(?:worth\\s+)?(\\d+|${NUMBER_WORD_PATTERN})\\s+points?\\b`,
|
||||
'gi',
|
||||
),
|
||||
(_, n) => `*${NUMBER_WORDS[n.toLowerCase()] || n} points`,
|
||||
)
|
||||
|
||||
const normalizeLabels = text =>
|
||||
text.replace(
|
||||
/\b(?:with\s+)?(?:hash\s?tag|labell?ed(?:\s+as)?|label|tagged(?:\s+as)?|tag)\s+([\p{L}\p{N}_]+)/giu,
|
||||
'#$1',
|
||||
)
|
||||
|
||||
const normalizeAssignees = (text, members = []) => {
|
||||
const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)'
|
||||
let out = text.replace(
|
||||
new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'),
|
||||
'@Anyone',
|
||||
)
|
||||
|
||||
for (const member of members) {
|
||||
const displayName = member.displayName
|
||||
if (!displayName) continue
|
||||
const firstName = displayName.split(/\s+/)[0]
|
||||
// Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin"
|
||||
const names = [...new Set([displayName, firstName])].filter(
|
||||
n => n.length > 1,
|
||||
)
|
||||
for (const name of names) {
|
||||
out = out.replace(
|
||||
new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'),
|
||||
`@${displayName}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const normalizeSpokenText = (text, { members = [] } = {}) => {
|
||||
let out = stripFillers(text)
|
||||
out = normalizePriority(out)
|
||||
out = normalizePoints(out)
|
||||
out = normalizeLabels(out)
|
||||
out = normalizeAssignees(out, members)
|
||||
return out.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// ── Multi-task segmentation ─────────────────────────────────────────────────
|
||||
// A pause (utterance boundary) always splits — that's handled upstream by the
|
||||
// recognizer. These spoken separators additionally split within one utterance.
|
||||
// Deliberately conservative: "and then" is NOT a separator ("wash and then
|
||||
// fold laundry" is one task).
|
||||
|
||||
const SEPARATOR_REGEX =
|
||||
/\s*\b(?:and\s+also|also|next\s+task|new\s+task|another\s+task)\b[,.]?\s*/gi
|
||||
|
||||
export const splitSpokenSegments = text =>
|
||||
text
|
||||
.split(SEPARATOR_REGEX)
|
||||
.map(s => s.trim().replace(/^[,.]\s*/, ''))
|
||||
.filter(Boolean)
|
||||
|
||||
// ── "Scratch that" correction ───────────────────────────────────────────────
|
||||
// Everything spoken before the command dies. If the command opens the
|
||||
// utterance ("…pause… scratch that"), the previously committed task dies
|
||||
// instead. Words after the command carry on as the replacement.
|
||||
|
||||
const SCRATCH_REGEX =
|
||||
/\s*\b(?:(?:scratch|forget|delete|remove|cancel)\s+(?:that|this|it|last(?:\s+one)?)|never\s?mind)\b[,.]?\s*/gi
|
||||
|
||||
export const applyScratchThat = text => {
|
||||
const parts = text.split(SCRATCH_REGEX)
|
||||
if (parts.length === 1) {
|
||||
return { text: text.trim(), dropPrevious: false }
|
||||
}
|
||||
const before = parts.slice(0, -1).join(' ').trim()
|
||||
const after = parts[parts.length - 1].trim()
|
||||
return { text: after, dropPrevious: before.length === 0 }
|
||||
}
|
||||
Reference in New Issue
Block a user