/**
* VoiceButton - Floating microphone button for voice interaction.
*
* Renders as a fixed FAB positioned above the mobile bottom navigation.
* Orchestrates the voice interaction lifecycle:
* 1. Click mic to start / stop quick listening (one-shot commands)
* 2. Display real-time transcript while listening
* 3. Parse final transcript into a command intent
* 4. Execute navigation or show the help modal
* 5. Speak the response via TTS
*
* Also provides:
* - Help button to open VoiceCommands modal
* - Chat button to open the full VoiceChat panel
* - First-time VoiceTutorial on initial interaction
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { MicrophoneIcon } from "@heroicons/react/24/solid";
import {
QuestionMarkCircleIcon,
ExclamationTriangleIcon,
XMarkIcon,
ChatBubbleLeftRightIcon,
} from "@heroicons/react/24/outline";
import PropTypes from "prop-types";
import useVoice from "@hooks/useVoice";
import useApp from "@hooks/useApp";
import useLocation from "@hooks/useLocation";
import {
parseCommand,
speak,
cancelSpeech,
checkCompatibility,
requestMicrophonePermission,
isTutorialShown,
getVoiceSettings,
INTENTS,
} from "@services/voiceService";
import { sendVoiceMessage } from "@services/voiceAssistantApi";
import VoiceTranscript from "./VoiceTranscript";
import VoiceCommands from "./VoiceCommands";
import VoiceChat from "./VoiceChat";
import VoiceTutorial from "./VoiceTutorial";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PERMISSION_GRANTED = "granted";
const PERMISSION_DENIED = "denied";
/** Duration (ms) to keep the command result visible before auto-dismissing. */
const RESULT_DISPLAY_DURATION_MS = 4000;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/** Intents that benefit from Groq-powered backend responses. */
const DATA_INTENTS = new Set([
INTENTS.QUERY_WEATHER_TIME,
INTENTS.NAVIGATE_WEATHER,
INTENTS.QUERY_CROP_PRICE,
INTENTS.NAVIGATE_APMC,
INTENTS.QUERY_DISEASE_TREATMENT,
INTENTS.NAVIGATE_DISEASE,
INTENTS.COMPARE_PRICES,
INTENTS.READ_ALERTS,
INTENTS.BEST_APMC,
]);
/**
* Build route state to pass entity data to target pages.
* This enables auto-filling search fields and triggering data fetches.
*/
function _buildRouteState(parsedResult, aiResult, userLocation) {
const state = { fromVoice: true };
const entities = parsedResult?.entities || {};
// Pass commodity for APMC page
if (entities.crops && entities.crops.length > 0) {
state.commodity = entities.crops[0];
}
// Pass location for weather page
if (userLocation && userLocation.taluka) {
state.location = userLocation;
}
// Also extract commodity from backend response if available
if (aiResult?.data?.commodity) {
state.commodity = aiResult.data.commodity;
}
return state;
}
function VoiceButton({ className = "" }) {
const navigate = useNavigate();
const { language } = useApp();
const locationCtx = useLocation();
const {
isSupported,
isListening,
transcript,
interimTranscript,
error: voiceError,
startListening,
stopListening,
clearError,
} = useVoice();
const [showTranscript, setShowTranscript] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showTutorial, setShowTutorial] = useState(false);
const [commandResult, setCommandResult] = useState(null);
const [micPermission, setMicPermission] = useState(null);
const [localError, setLocalError] = useState(null);
const lastProcessedTranscript = useRef("");
const compatibility = useRef(checkCompatibility());
const hasInteracted = useRef(false);
// ---------------------------------------------------------------------------
// Process final transcript into a command (one-shot mode)
// ---------------------------------------------------------------------------
useEffect(() => {
if (showChat) return; // Chat handles its own transcript processing
if (!transcript || transcript === lastProcessedTranscript.current) return;
lastProcessedTranscript.current = transcript;
const result = parseCommand(transcript, language);
setCommandResult(result);
// Navigation-only commands (home, help)
if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5) {
if (result.intent === INTENTS.SHOW_HELP) {
setShowCommands(true);
const settings = getVoiceSettings();
if (settings.autoSpeak) {
speak(result.response, language, settings).catch(() => {});
}
return;
}
}
// For data-driven intents, call the Groq-powered backend
if (DATA_INTENTS.has(result.intent) && result.confidence >= 0.5) {
const location = locationCtx.hasLocation
? { state: locationCtx.state, district: locationCtx.district, taluka: locationCtx.taluka }
: null;
// Show loading state
setCommandResult({
...result,
response: language === "hi" ? "जानकारी ला रहा हूँ..." : "Fetching information...",
});
sendVoiceMessage({ message: transcript, language, location })
.then((aiResult) => {
setCommandResult({
intent: result.intent,
confidence: result.confidence,
route: aiResult.navigate_to || result.route,
response: aiResult.response,
});
const settings = getVoiceSettings();
if (settings.autoSpeak) {
speak(aiResult.response, language, settings).catch(() => {});
}
// Navigate with entity data as route state
const targetRoute = aiResult.navigate_to || result.route;
if (targetRoute) {
const routeState = _buildRouteState(result, aiResult, location);
navigate(targetRoute, { state: routeState });
}
})
.catch(() => {
// Fallback: navigate and speak the basic response
setCommandResult(result);
if (result.route) {
const routeState = _buildRouteState(result, null, location);
navigate(result.route, { state: routeState });
}
const settings = getVoiceSettings();
if (settings.autoSpeak) {
speak(result.response, language, settings).catch(() => {});
}
});
return;
}
// Non-data intents: navigate and speak directly
if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5 && result.route) {
navigate(result.route);
}
const settings = getVoiceSettings();
if (settings.autoSpeak) {
speak(result.response, language, settings).catch(() => {});
}
}, [transcript, language, navigate, showChat, locationCtx]);
// ---------------------------------------------------------------------------
// Show / hide transcript panel alongside listening state
// ---------------------------------------------------------------------------
useEffect(() => {
if (isListening && !showChat) {
setShowTranscript(true);
setCommandResult(null);
}
}, [isListening, showChat]);
// ---------------------------------------------------------------------------
// Auto-dismiss command result after a short delay
// ---------------------------------------------------------------------------
useEffect(() => {
if (!commandResult) return;
const timer = setTimeout(() => {
setCommandResult(null);
setShowTranscript(false);
}, RESULT_DISPLAY_DURATION_MS);
return () => clearTimeout(timer);
}, [commandResult]);
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const ensureMicPermission = useCallback(async () => {
if (!compatibility.current.isFullySupported) {
setLocalError(
"Voice recognition is not supported in this browser. Please use Chrome or Edge.",
);
return false;
}
if (micPermission !== PERMISSION_GRANTED) {
const state = await requestMicrophonePermission();
setMicPermission(state);
if (state === PERMISSION_DENIED) {
setLocalError(
"Microphone access was denied. Please allow microphone permissions in your browser settings.",
);
return false;
}
}
return true;
}, [micPermission]);
const maybeShowTutorial = useCallback(() => {
if (!hasInteracted.current && !isTutorialShown()) {
hasInteracted.current = true;
setShowTutorial(true);
return true;
}
hasInteracted.current = true;
return false;
}, []);
const handleToggleListening = useCallback(async () => {
if (isListening) {
stopListening();
return;
}
// Show tutorial on first interaction
if (maybeShowTutorial()) return;
const permitted = await ensureMicPermission();
if (!permitted) return;
clearError();
setLocalError(null);
setCommandResult(null);
lastProcessedTranscript.current = "";
startListening();
}, [
isListening,
maybeShowTutorial,
ensureMicPermission,
clearError,
startListening,
stopListening,
]);
const handleOpenChat = useCallback(() => {
// Show tutorial on first interaction
if (maybeShowTutorial()) return;
if (isListening) {
stopListening();
}
setShowTranscript(false);
cancelSpeech();
setShowChat(true);
}, [maybeShowTutorial, isListening, stopListening]);
const handleCloseChat = useCallback(() => {
setShowChat(false);
}, []);
const handleDismissError = useCallback(() => {
setLocalError(null);
clearError();
}, [clearError]);
const handleCloseTranscript = useCallback(() => {
setShowTranscript(false);
if (isListening) {
stopListening();
}
cancelSpeech();
}, [isListening, stopListening]);
const handleCloseTutorial = useCallback(() => {
setShowTutorial(false);
}, []);
// ---------------------------------------------------------------------------
// Render guards
// ---------------------------------------------------------------------------
if (!isSupported && !compatibility.current.speechRecognition) {
return null;
}
const displayError = localError || voiceError;
// When chat is open, only render the chat panel (hide FAB)
if (showChat) {
return (
<>