From eabb018c642045f2cfef8fd61b86bda98c7e4063 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:27:42 +0100 Subject: [PATCH] feat(capture): hold mobile + button to record voice thoughts directly to inbox --- Doku/ui-patterns.md | 11 + frontend/src/App.tsx | 16 +- .../src/features/capture/HoldToRecordFab.tsx | 359 ++++++++++++++++++ .../__tests__/HoldToRecordFab.test.tsx | 257 +++++++++++++ frontend/src/features/help/HelpView.tsx | 2 +- frontend/src/test/setup.ts | 13 + 6 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/capture/HoldToRecordFab.tsx create mode 100644 frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 4b5a34b..ef49c9e 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf. - `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt. +## +-Button: Tippen und Halten + +Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx) + +- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus. +- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript. +- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`). +- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`). +- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein. + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4012f6..799600a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { ThemeId, } from './lib/theme'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; +import { HoldToRecordFab } from './features/capture/HoldToRecordFab'; import { CountBadge } from './components/CountBadge'; import { TriageDeck } from './features/triage/TriageDeck'; import { TasksView } from './features/triage/TasksView'; @@ -1174,9 +1175,8 @@ export const App: React.FC = () => { {/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
- + onTranscript={async (text) => { + const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' }); + handleCaptured(thought, 'inbox'); + }} + />
{/* Col 4: Aufgaben */} diff --git a/frontend/src/features/capture/HoldToRecordFab.tsx b/frontend/src/features/capture/HoldToRecordFab.tsx new file mode 100644 index 0000000..43b2f7a --- /dev/null +++ b/frontend/src/features/capture/HoldToRecordFab.tsx @@ -0,0 +1,359 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Mic, Plus } from 'lucide-react'; +import { triggerHaptic } from '../../lib/api'; + +export interface HoldToRecordFabProps { + onTap: () => void; + onTranscript: (text: string) => Promise | void; +} + +const HOLD_DELAY_MS = 350; +const CANCEL_DRAG_PX = 60; +const STOP_TIMEOUT_MS = 1500; +const TOAST_DURATION_MS = 2500; + +export const HoldToRecordFab: React.FC = ({ onTap, onTranscript }) => { + const [isRecording, setIsRecording] = useState(false); + const [isCancelZone, setIsCancelZone] = useState(false); + const [liveTranscript, setLiveTranscript] = useState(''); + const [toastMessage, setToastMessage] = useState(null); + + const isPressedRef = useRef(false); + const didEnterHoldRef = useRef(false); + const fallbackHandledRef = useRef(false); + const isCancelZoneRef = useRef(false); + const abortedRef = useRef(false); + const isStoppingRef = useRef(false); + const hasEndedRef = useRef(false); + const finalizedRef = useRef(false); + const startYRef = useRef(0); + const transcriptRef = useRef(''); + + const recognitionRef = useRef(null); + const holdTimerRef = useRef(null); + const stopTimerRef = useRef(null); + const toastTimerRef = useRef(null); + + const clearHoldTimer = () => { + if (holdTimerRef.current !== null) { + window.clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + }; + + const clearStopTimer = () => { + if (stopTimerRef.current !== null) { + window.clearTimeout(stopTimerRef.current); + stopTimerRef.current = null; + } + }; + + const showToast = (msg: string) => { + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + setToastMessage(msg); + toastTimerRef.current = window.setTimeout(() => { + setToastMessage(null); + toastTimerRef.current = null; + }, TOAST_DURATION_MS); + }; + + useEffect(() => { + return () => { + clearHoldTimer(); + clearStopTimer(); + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + if (recognitionRef.current) { + try { + recognitionRef.current.abort?.(); + } catch { + // ignore cleanup errors + } + } + }; + }, []); + + const finalizeRecording = async () => { + if (finalizedRef.current) return; + finalizedRef.current = true; + clearStopTimer(); + recognitionRef.current = null; + + if (abortedRef.current) return; + + const text = transcriptRef.current.trim(); + setLiveTranscript(''); + if (!text) { + showToast('Nichts erkannt'); + return; + } + + triggerHaptic(25); + showToast('Im Quick Dump gespeichert'); + try { + await onTranscript(text); + } catch { + showToast('Fehler beim Speichern'); + } + }; + + const startVoiceRecording = () => { + const SpeechRecognition = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + + if (!SpeechRecognition) { + fallbackHandledRef.current = true; + showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); + onTap(); + return; + } + + try { + const recognition = new SpeechRecognition(); + recognition.lang = 'de-DE'; + recognition.interimResults = true; + recognition.continuous = true; + + transcriptRef.current = ''; + abortedRef.current = false; + isStoppingRef.current = false; + hasEndedRef.current = false; + finalizedRef.current = false; + isCancelZoneRef.current = false; + + recognition.onresult = (event: any) => { + let combined = ''; + for (let i = 0; i < event.results.length; i++) { + combined += event.results[i][0].transcript; + } + transcriptRef.current = combined; + setLiveTranscript(combined); + }; + + recognition.onerror = (event: any) => { + const errCode = event?.error; + if (errCode === 'not-allowed' || errCode === 'service-not-allowed') { + abortedRef.current = true; + clearStopTimer(); + setIsRecording(false); + setIsCancelZone(false); + setLiveTranscript(''); + if (!fallbackHandledRef.current) { + fallbackHandledRef.current = true; + showToast('Mikrofon nicht freigegeben'); + onTap(); + } + return; + } + if (isStoppingRef.current) { + void finalizeRecording(); + } + }; + + recognition.onend = () => { + hasEndedRef.current = true; + if (isStoppingRef.current) { + void finalizeRecording(); + } + }; + + recognitionRef.current = recognition; + setLiveTranscript(''); + setIsCancelZone(false); + setIsRecording(true); + triggerHaptic([15, 60, 25]); + recognition.start(); + } catch { + fallbackHandledRef.current = true; + setIsRecording(false); + showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); + onTap(); + } + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.pointerType === 'mouse' && e.button !== 0) return; + + try { + e.currentTarget.setPointerCapture?.(e.pointerId); + } catch { + // ignore if unsupported in environment + } + + clearHoldTimer(); + clearStopTimer(); + + isPressedRef.current = true; + didEnterHoldRef.current = false; + fallbackHandledRef.current = false; + isCancelZoneRef.current = false; + startYRef.current = e.clientY; + + holdTimerRef.current = window.setTimeout(() => { + holdTimerRef.current = null; + if (!isPressedRef.current) return; + didEnterHoldRef.current = true; + startVoiceRecording(); + }, HOLD_DELAY_MS); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return; + const deltaUp = startYRef.current - e.clientY; + const inCancel = deltaUp > CANCEL_DRAG_PX; + if (inCancel !== isCancelZoneRef.current) { + isCancelZoneRef.current = inCancel; + setIsCancelZone(inCancel); + triggerHaptic(10); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // ignore + } + + if (!isPressedRef.current) return; + isPressedRef.current = false; + clearHoldTimer(); + + if (!didEnterHoldRef.current) { + onTap(); + return; + } + + if (fallbackHandledRef.current) { + return; + } + + setIsRecording(false); + setIsCancelZone(false); + + if (isCancelZoneRef.current) { + abortedRef.current = true; + clearStopTimer(); + setLiveTranscript(''); + triggerHaptic(15); + try { + recognitionRef.current?.abort?.(); + } catch { + // ignore + } + recognitionRef.current = null; + return; + } + + isStoppingRef.current = true; + if (hasEndedRef.current || !recognitionRef.current) { + void finalizeRecording(); + return; + } + + stopTimerRef.current = window.setTimeout(() => { + void finalizeRecording(); + }, STOP_TIMEOUT_MS); + + try { + recognitionRef.current.stop(); + } catch { + void finalizeRecording(); + } + }; + + const handlePointerCancel = (e: React.PointerEvent) => { + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // ignore + } + + if (!isPressedRef.current) return; + isPressedRef.current = false; + clearHoldTimer(); + clearStopTimer(); + + if (didEnterHoldRef.current && !fallbackHandledRef.current) { + abortedRef.current = true; + setIsRecording(false); + setIsCancelZone(false); + setLiveTranscript(''); + try { + recognitionRef.current?.abort?.(); + } catch { + // ignore + } + recognitionRef.current = null; + } + }; + + const displayedTranscript = + liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript; + + return ( + <> + {isRecording && ( +
+ {isCancelZone ? ( +
🗑️ Zum Abbrechen loslassen
+ ) : ( +
+
+ Ich höre zu… (loslassen speichert) +
+ {displayedTranscript ? ( +
+ {displayedTranscript} +
+ ) : ( +
Nach oben ziehen zum Abbrechen
+ )} +
+ )} +
+ )} + + {!isRecording && toastMessage && ( +
+ {toastMessage} +
+ )} + + + + ); +}; diff --git a/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx new file mode 100644 index 0000000..4ee6863 --- /dev/null +++ b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { HoldToRecordFab } from '../HoldToRecordFab'; + +class FakeSpeechRecognition { + static instances: FakeSpeechRecognition[] = []; + + lang = ''; + interimResults = false; + continuous = false; + started = false; + stopped = false; + aborted = false; + + onresult: ((event: any) => void) | null = null; + onerror: ((event: any) => void) | null = null; + onend: (() => void) | null = null; + + constructor() { + FakeSpeechRecognition.instances.push(this); + } + + start() { + this.started = true; + } + + stop() { + this.stopped = true; + this.onend?.(); + } + + abort() { + this.aborted = true; + this.onend?.(); + } + + emitResult(transcript: string) { + this.onresult?.({ + results: [[{ transcript }]], + }); + } + + emitError(error: string) { + this.onerror?.({ error }); + } +} + +describe('HoldToRecordFab', () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeSpeechRecognition.instances = []; + (window as any).SpeechRecognition = FakeSpeechRecognition; + delete (window as any).webkitSpeechRecognition; + }); + + afterEach(() => { + vi.useRealTimers(); + delete (window as any).SpeechRecognition; + delete (window as any).webkitSpeechRecognition; + }); + + it('calls onTap on a short tap (< 350 ms)', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(150); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(FakeSpeechRecognition.instances).toHaveLength(0); + }); + + it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn().mockResolvedValue(undefined); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(FakeSpeechRecognition.instances).toHaveLength(1); + const rec = FakeSpeechRecognition.instances[0]; + expect(rec.started).toBe(true); + expect(rec.lang).toBe('de-DE'); + expect(rec.interimResults).toBe(true); + expect(rec.continuous).toBe(true); + expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy(); + + act(() => { + rec.emitResult('Milch und Kaffee einkaufen'); + }); + expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy(); + + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 600 }); + }); + + expect(rec.stopped).toBe(true); + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen'); + expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert'); + }); + + it('cancels recording when dragged up by more than 60 px and released', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitResult('Verwerfen Test'); + }); + + // Drag up by 80px (> 60px threshold) + fireEvent.pointerMove(btn, { clientY: 520 }); + expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy(); + + fireEvent.pointerUp(btn, { clientY: 520 }); + + expect(rec.aborted).toBe(true); + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).not.toHaveBeenCalled(); + }); + + it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn().mockResolvedValue(undefined); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitResult('Doch behalten'); + }); + + // Drag up into cancel zone + fireEvent.pointerMove(btn, { clientY: 510 }); + expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy(); + + // Drag back down below threshold + fireEvent.pointerMove(btn, { clientY: 580 }); + expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy(); + + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 580 }); + }); + + expect(rec.aborted).toBe(false); + expect(onTranscript).toHaveBeenCalledWith('Doch behalten'); + }); + + it('shows "Nichts erkannt" and does not save when transcript is empty', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 600 }); + }); + + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain('Nichts erkannt'); + }); + + it('falls back to onTap and shows message when microphone permission is denied', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitError('not-allowed'); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben'); + }); + + it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => { + delete (window as any).SpeechRecognition; + delete (window as any).webkitSpeechRecognition; + + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain( + 'Spracheingabe wird von diesem Browser nicht unterstützt' + ); + }); +}); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9c0df9a..d6b428c 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -413,7 +413,7 @@ export const HelpView: React.FC = ({ onBack }) => { Sofortiger Fokus: Drücke irgendwo in der App C oder / (sofern du nicht bereits in einem Textfeld tippst).
  • - Sprach-Diktat: Klicke auf das Mikrofon-Symbol () für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen. + Sprach-Diktat: Klicke auf das Mikrofon-Symbol () für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen. Auf dem Smartphone kannst du außerdem den runden +-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
  • Ziel-Auswahl: Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als Aufgabe (mit Priorität und Fälligkeit), Listeneintrag (z.B. auf die Einkaufsliste) oder neue Notiz routen. diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 01a7765..c376651 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react'; // jsdom has no layout engine, so scrolling is a no-op here Element.prototype.scrollIntoView = () => {}; +if (typeof window !== 'undefined' && !window.PointerEvent) { + class PolyfillPointerEvent extends MouseEvent { + pointerId: number; + pointerType: string; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 1; + this.pointerType = params.pointerType ?? 'touch'; + } + } + (window as any).PointerEvent = PolyfillPointerEvent; +} + afterEach(() => { cleanup(); });