Fix hydration error in VoiceFloatingButton - remove nested buttons
This commit is contained in:
@@ -1,10 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Fab, Tooltip, Snackbar, Alert } from '@mui/material';
|
||||
import {
|
||||
Fab,
|
||||
Tooltip,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
import MicOffIcon from '@mui/icons-material/MicOff';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useVoiceInput } from '@/hooks/useVoiceInput';
|
||||
|
||||
/**
|
||||
* Floating voice input button
|
||||
@@ -14,6 +30,9 @@ import { useRouter } from 'next/navigation';
|
||||
*/
|
||||
export function VoiceFloatingButton() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [classificationResult, setClassificationResult] = useState<any>(null);
|
||||
const [snackbar, setSnackbar] = useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
@@ -24,18 +43,83 @@ export function VoiceFloatingButton() {
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
const handleTranscript = (transcript: string) => {
|
||||
console.log('[Voice] Transcript:', transcript);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `Command received: "${transcript}"`,
|
||||
severity: 'info',
|
||||
});
|
||||
const { isListening, isSupported, transcript, error, startListening, stopListening, reset } =
|
||||
useVoiceInput();
|
||||
|
||||
// Auto-classify when we get a final transcript
|
||||
React.useEffect(() => {
|
||||
if (transcript && !isListening && !isProcessing && open) {
|
||||
classifyTranscript(transcript);
|
||||
}
|
||||
}, [transcript, isListening, isProcessing, open]);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!isSupported) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Voice input not supported in this browser. Please use Chrome, Edge, or Safari.',
|
||||
severity: 'error',
|
||||
});
|
||||
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);
|
||||
handleClassifiedIntent(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 handleClassifiedIntent = (result: any) => {
|
||||
console.log('[Voice] Classification:', result);
|
||||
|
||||
if (result.error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
@@ -52,9 +136,9 @@ export function VoiceFloatingButton() {
|
||||
severity: 'success',
|
||||
});
|
||||
|
||||
// Navigate to appropriate page based on intent
|
||||
// This is a placeholder - in production, you'd create the activity
|
||||
// Auto-close dialog and navigate
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
if (result.intent === 'feeding') {
|
||||
router.push('/track/feeding');
|
||||
} else if (result.intent === 'sleep') {
|
||||
@@ -76,6 +160,8 @@ export function VoiceFloatingButton() {
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="voice input"
|
||||
onClick={handleOpen}
|
||||
disabled={!isSupported}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
@@ -83,15 +169,116 @@ export function VoiceFloatingButton() {
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<VoiceInputButton
|
||||
onTranscript={handleTranscript}
|
||||
onClassifiedIntent={handleClassifiedIntent}
|
||||
size="large"
|
||||
variant="fab"
|
||||
/>
|
||||
<MicIcon />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
|
||||
{/* Voice input dialog */}
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Voice Command
|
||||
{classificationResult && !classificationResult.error && (
|
||||
<Chip
|
||||
label={`${classificationResult.intent} (${classificationResult.confidenceLevel})`}
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
{/* Microphone animation */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-block', mb: 3 }}>
|
||||
<IconButton
|
||||
color={isListening ? 'error' : 'primary'}
|
||||
onClick={isListening ? handleStopListening : handleStartListening}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
bgcolor: isListening ? 'error.light' : 'primary.light',
|
||||
'&:hover': {
|
||||
bgcolor: isListening ? 'error.main' : 'primary.main',
|
||||
},
|
||||
animation: isListening ? 'pulse 1.5s infinite' : 'none',
|
||||
'@keyframes pulse': {
|
||||
'0%': { transform: 'scale(1)', opacity: 1 },
|
||||
'50%': { transform: 'scale(1.1)', opacity: 0.8 },
|
||||
'100%': { transform: 'scale(1)', opacity: 1 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isListening ? <MicIcon sx={{ fontSize: 48 }} /> : <MicOffIcon sx={{ fontSize: 48 }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Status text */}
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
{isListening ? 'Listening... Speak now' : 'Click the microphone to start'}
|
||||
</Typography>
|
||||
|
||||
{/* Transcript */}
|
||||
{transcript && (
|
||||
<Box sx={{ mt: 3, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Transcript:
|
||||
</Typography>
|
||||
<Typography variant="body1">{transcript}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Processing command...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Classification result */}
|
||||
{classificationResult && !classificationResult.error && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Understood:</strong> {classificationResult.intent}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error messages */}
|
||||
{(error || (classificationResult && classificationResult.error)) && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error || classificationResult.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{!transcript && !isListening && (
|
||||
<Box sx={{ mt: 3, textAlign: 'left' }}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
|
||||
Example commands:
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
• "Fed baby 120 ml"
|
||||
<br />
|
||||
• "Nursed on left breast for 15 minutes"
|
||||
<br />
|
||||
• "Changed wet diaper"
|
||||
<br />
|
||||
• "Baby napped for 45 minutes"
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Snackbar for feedback */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
@@ -99,11 +286,7 @@ export function VoiceFloatingButton() {
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseSnackbar}
|
||||
severity={snackbar.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
Reference in New Issue
Block a user