diff --git a/maternal-web/app/layout.tsx b/maternal-web/app/layout.tsx
index af27e06..b47aac1 100644
--- a/maternal-web/app/layout.tsx
+++ b/maternal-web/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google';
import { ThemeRegistry } from '@/components/ThemeRegistry';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { ReduxProvider } from '@/components/providers/ReduxProvider';
+import { VoiceFloatingButton } from '@/components/voice/VoiceFloatingButton';
// import { PerformanceMonitor } from '@/components/common/PerformanceMonitor'; // Temporarily disabled
import './globals.css';
@@ -44,6 +45,7 @@ export default function RootLayout({
{/* */}
{children}
+
diff --git a/maternal-web/app/track/feeding/page.tsx b/maternal-web/app/track/feeding/page.tsx
index 14cd3b1..9cd2ba2 100644
--- a/maternal-web/app/track/feeding/page.tsx
+++ b/maternal-web/app/track/feeding/page.tsx
@@ -48,6 +48,7 @@ import { withErrorBoundary } from '@/components/common/ErrorFallbacks';
import { useAuth } from '@/lib/auth/AuthContext';
import { trackingApi, Activity } from '@/lib/api/tracking';
import { childrenApi, Child } from '@/lib/api/children';
+import { VoiceInputButton } from '@/components/voice/VoiceInputButton';
import { motion } from 'framer-motion';
import { formatDistanceToNow } from 'date-fns';
@@ -350,9 +351,31 @@ function FeedingTrackPage() {
router.back()} sx={{ mr: 2 }}>
-
+
Track Feeding
+ {
+ console.log('[Feeding] Voice transcript:', transcript);
+ }}
+ onClassifiedIntent={(result) => {
+ if (result.intent === 'feeding' && result.structuredData) {
+ const data = result.structuredData;
+ // Auto-fill form with voice data
+ if (data.type === 'bottle' && data.amount) {
+ setFeedingType('bottle');
+ setAmount(data.amount.toString());
+ } else if (data.type?.includes('breast')) {
+ setFeedingType('breast');
+ if (data.side) setSide(data.side);
+ if (data.duration) setDuration(data.duration.toString());
+ } else if (data.type === 'solid') {
+ setFeedingType('solid');
+ }
+ }
+ }}
+ size="medium"
+ />
{error && (
diff --git a/maternal-web/components/voice/VoiceFloatingButton.tsx b/maternal-web/components/voice/VoiceFloatingButton.tsx
new file mode 100644
index 0000000..581a97a
--- /dev/null
+++ b/maternal-web/components/voice/VoiceFloatingButton.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Fab, Tooltip, Snackbar, Alert } from '@mui/material';
+import MicIcon from '@mui/icons-material/Mic';
+import { VoiceInputButton } from './VoiceInputButton';
+import { useRouter } from 'next/navigation';
+
+/**
+ * Floating voice input button
+ *
+ * Always visible floating action button for quick voice commands.
+ * Positioned in bottom-right corner for easy thumb access.
+ */
+export function VoiceFloatingButton() {
+ const router = useRouter();
+ const [snackbar, setSnackbar] = useState<{
+ open: boolean;
+ message: string;
+ severity: 'success' | 'info' | 'warning' | 'error';
+ }>({
+ open: false,
+ message: '',
+ severity: 'info',
+ });
+
+ const handleTranscript = (transcript: string) => {
+ console.log('[Voice] Transcript:', transcript);
+ setSnackbar({
+ open: true,
+ message: `Command received: "${transcript}"`,
+ severity: 'info',
+ });
+ };
+
+ const handleClassifiedIntent = (result: any) => {
+ console.log('[Voice] Classification:', result);
+
+ if (result.error) {
+ setSnackbar({
+ open: true,
+ message: result.message,
+ severity: 'error',
+ });
+ return;
+ }
+
+ // Show success message
+ setSnackbar({
+ open: true,
+ message: `Understood: ${result.intent} command`,
+ severity: 'success',
+ });
+
+ // Navigate to appropriate page based on intent
+ // This is a placeholder - in production, you'd create the activity
+ setTimeout(() => {
+ if (result.intent === 'feeding') {
+ router.push('/track/feeding');
+ } else if (result.intent === 'sleep') {
+ router.push('/track/sleep');
+ } else if (result.intent === 'diaper') {
+ router.push('/track/diaper');
+ }
+ }, 1500);
+ };
+
+ const handleCloseSnackbar = () => {
+ setSnackbar(prev => ({ ...prev, open: false }));
+ };
+
+ return (
+ <>
+ {/* Floating button positioned in bottom-right */}
+
+
+
+
+
+
+ {/* Snackbar for feedback */}
+
+
+ {snackbar.message}
+
+
+ >
+ );
+}
diff --git a/maternal-web/components/voice/VoiceInputButton.tsx b/maternal-web/components/voice/VoiceInputButton.tsx
new file mode 100644
index 0000000..e934008
--- /dev/null
+++ b/maternal-web/components/voice/VoiceInputButton.tsx
@@ -0,0 +1,298 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import {
+ IconButton,
+ Tooltip,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Box,
+ Typography,
+ CircularProgress,
+ Alert,
+ Chip,
+} from '@mui/material';
+import MicIcon from '@mui/icons-material/Mic';
+import MicOffIcon from '@mui/icons-material/MicOff';
+import { useVoiceInput } from '@/hooks/useVoiceInput';
+
+export interface VoiceInputButtonProps {
+ onTranscript: (transcript: string) => void;
+ onClassifiedIntent?: (result: any) => void;
+ size?: 'small' | 'medium' | 'large';
+ variant?: 'icon' | 'fab';
+}
+
+/**
+ * Voice input button component
+ *
+ * Displays microphone button that opens dialog for voice recording.
+ * Uses Web Speech API for real-time transcription.
+ */
+export function VoiceInputButton({
+ onTranscript,
+ onClassifiedIntent,
+ size = 'medium',
+ variant = 'icon',
+}: VoiceInputButtonProps) {
+ const [open, setOpen] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [classificationResult, setClassificationResult] = useState(null);
+
+ const { isListening, isSupported, transcript, error, startListening, stopListening, reset } =
+ useVoiceInput();
+
+ // Auto-classify when we get a final transcript
+ useEffect(() => {
+ if (transcript && !isListening && !isProcessing) {
+ classifyTranscript(transcript);
+ }
+ }, [transcript, isListening, isProcessing]);
+
+ const handleOpen = () => {
+ if (!isSupported) {
+ alert('Voice input is not supported in your browser. Please use Chrome, Edge, or Safari.');
+ return;
+ }
+ setOpen(true);
+ reset();
+ setClassificationResult(null);
+ };
+
+ const handleClose = () => {
+ if (isListening) {
+ stopListening();
+ }
+ setOpen(false);
+ reset();
+ setClassificationResult(null);
+ };
+
+ const handleStartListening = () => {
+ reset();
+ setClassificationResult(null);
+ startListening();
+ };
+
+ const handleStopListening = () => {
+ stopListening();
+ };
+
+ const classifyTranscript = async (text: string) => {
+ setIsProcessing(true);
+ try {
+ const response = await fetch('/api/voice/transcribe', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ text }),
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ setClassificationResult(data.classification);
+ if (onClassifiedIntent) {
+ onClassifiedIntent(data.classification);
+ }
+ } else {
+ setClassificationResult({
+ error: true,
+ message: data.message || 'Could not understand command',
+ });
+ }
+ } catch (error) {
+ console.error('[Voice] Classification error:', error);
+ setClassificationResult({
+ error: true,
+ message: 'Failed to process command',
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleUseTranscript = () => {
+ if (transcript) {
+ onTranscript(transcript);
+ handleClose();
+ }
+ };
+
+ const renderButton = () => {
+ const icon = isListening ? : ;
+ const title = isSupported
+ ? 'Voice input'
+ : 'Voice input not supported in this browser';
+
+ if (variant === 'fab') {
+ return (
+
+
+ {icon}
+
+
+ );
+ }
+
+ return (
+
+
+ {icon}
+
+
+ );
+ };
+
+ return (
+ <>
+ {renderButton()}
+
+
+ >
+ );
+}
diff --git a/maternal-web/hooks/useVoiceInput.ts b/maternal-web/hooks/useVoiceInput.ts
new file mode 100644
index 0000000..c5aaa5d
--- /dev/null
+++ b/maternal-web/hooks/useVoiceInput.ts
@@ -0,0 +1,182 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+export interface VoiceInputResult {
+ transcript: string;
+ confidence: number;
+ isFinal: boolean;
+}
+
+export interface VoiceInputState {
+ isListening: boolean;
+ isSupported: boolean;
+ transcript: string;
+ error: string | null;
+}
+
+/**
+ * Hook for voice input using browser Web Speech API
+ *
+ * Provides voice recording functionality with real-time transcription.
+ * Falls back gracefully if browser doesn't support Speech Recognition.
+ */
+export function useVoiceInput() {
+ const [state, setState] = useState({
+ isListening: false,
+ isSupported: false,
+ transcript: '',
+ error: null,
+ });
+
+ const recognitionRef = useRef(null);
+ const timeoutRef = useRef(null);
+
+ // Check if browser supports Speech Recognition
+ useEffect(() => {
+ const SpeechRecognition =
+ (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
+
+ if (SpeechRecognition) {
+ setState(prev => ({ ...prev, isSupported: true }));
+
+ // Initialize recognition
+ const recognition = new SpeechRecognition();
+ recognition.continuous = false; // Single recognition
+ recognition.interimResults = true; // Get interim results
+ recognition.maxAlternatives = 1;
+ recognition.lang = 'en-US'; // Default language
+
+ recognitionRef.current = recognition;
+ } else {
+ setState(prev => ({ ...prev, isSupported: false }));
+ }
+
+ return () => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ // Start listening
+ const startListening = useCallback(() => {
+ if (!recognitionRef.current) {
+ setState(prev => ({
+ ...prev,
+ error: 'Speech recognition not supported in this browser',
+ }));
+ return;
+ }
+
+ const recognition = recognitionRef.current;
+
+ // Clear previous state
+ setState(prev => ({
+ ...prev,
+ isListening: true,
+ transcript: '',
+ error: null,
+ }));
+
+ // Set up event handlers
+ recognition.onstart = () => {
+ console.log('[Voice] Started listening');
+ };
+
+ recognition.onresult = (event: any) => {
+ let interimTranscript = '';
+ let finalTranscript = '';
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const transcript = event.results[i][0].transcript;
+ if (event.results[i].isFinal) {
+ finalTranscript += transcript;
+ } else {
+ interimTranscript += transcript;
+ }
+ }
+
+ setState(prev => ({
+ ...prev,
+ transcript: finalTranscript || interimTranscript,
+ }));
+ };
+
+ recognition.onerror = (event: any) => {
+ console.error('[Voice] Error:', event.error);
+
+ let errorMessage = 'Failed to recognize speech';
+ if (event.error === 'no-speech') {
+ errorMessage = 'No speech detected. Please try again.';
+ } else if (event.error === 'audio-capture') {
+ errorMessage = 'No microphone found. Please check your settings.';
+ } else if (event.error === 'not-allowed') {
+ errorMessage = 'Microphone access denied. Please grant permission.';
+ } else if (event.error === 'network') {
+ errorMessage = 'Network error. Please check your connection.';
+ }
+
+ setState(prev => ({
+ ...prev,
+ isListening: false,
+ error: errorMessage,
+ }));
+ };
+
+ recognition.onend = () => {
+ console.log('[Voice] Stopped listening');
+ setState(prev => ({
+ ...prev,
+ isListening: false,
+ }));
+ };
+
+ // Auto-stop after 10 seconds
+ timeoutRef.current = setTimeout(() => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ }, 10000);
+
+ // Start recognition
+ try {
+ recognition.start();
+ } catch (error) {
+ console.error('[Voice] Failed to start:', error);
+ setState(prev => ({
+ ...prev,
+ isListening: false,
+ error: 'Failed to start voice recognition',
+ }));
+ }
+ }, []);
+
+ // Stop listening
+ const stopListening = useCallback(() => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }, []);
+
+ // Reset state
+ const reset = useCallback(() => {
+ setState(prev => ({
+ ...prev,
+ transcript: '',
+ error: null,
+ }));
+ }, []);
+
+ return {
+ ...state,
+ startListening,
+ stopListening,
+ reset,
+ };
+}