From 637462bff4d426f604e4c77b5bf03a60a39c0375 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 30 Jan 2026 10:41:58 +0100 Subject: [PATCH 01/29] add: Voice Bot WebSocket API documentation for real-time interaction --- .claude/settings.local.json | 7 +- src/components/BotChat/VoiceChatModal.tsx | 91 ++- src/hooks/useVoiceChat.ts | 456 +++++------- src/services/botservice.ts | 473 ++++++------- src/utils/audioUtils.ts | 822 +++++----------------- voice_bot_websocket_api.md | 375 ++++++++++ 6 files changed, 1016 insertions(+), 1208 deletions(-) create mode 100644 voice_bot_websocket_api.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 20c5f4a..c83e9b7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -50,7 +50,12 @@ "Bash(powershell -Command \"Get-Content ''e:\\\\MyTaskly\\\\MyTaskly-app\\\\src\\\\locales\\\\en.json'' | Select-Object -Last 20\")", "Bash(powershell -Command \"Get-Content ''e:\\\\MyTaskly\\\\MyTaskly-app\\\\src\\\\locales\\\\it.json'' | Select-Object -Last 20\")", "Bash(npm ls:*)", - "Bash(curl:*)" + "Bash(curl:*)", + "Bash(export NVM_DIR=\"$HOME/.nvm\")", + "Bash([ -s \"$NVM_DIR/nvm.sh\" ])", + "Bash(. \"$NVM_DIR/nvm.sh\")", + "Bash(npm test:*)", + "Bash(xargs:*)" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 592efaf..38741b2 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -12,7 +12,7 @@ import { Alert } from "react-native"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; -import { useVoiceChat, VoiceChatState } from '../../hooks/useVoiceChat'; +import { useVoiceChat } from '../../hooks/useVoiceChat'; export interface VoiceChatModalProps { visible: boolean; @@ -26,7 +26,6 @@ const { height } = Dimensions.get("window"); const VoiceChatModal: React.FC = ({ visible, onClose, - isRecording: externalIsRecording = false, onVoiceResponse, }) => { const { @@ -38,12 +37,13 @@ const VoiceChatModal: React.FC = ({ isProcessing, isSpeaking, isSpeechActive, + transcripts, + activeTools, connect, disconnect, - stopRecording, cancelRecording, stopPlayback, - sendControl, + sendInterrupt, requestPermissions, } = useVoiceChat(); @@ -54,6 +54,16 @@ const VoiceChatModal: React.FC = ({ const fadeIn = useRef(new Animated.Value(0)).current; const recordingScale = useRef(new Animated.Value(1)).current; + // Notifica trascrizioni assistant al parent + useEffect(() => { + if (onVoiceResponse && transcripts.length > 0) { + const last = transcripts[transcripts.length - 1]; + if (last.role === 'assistant') { + onVoiceResponse(last.content); + } + } + }, [transcripts, onVoiceResponse]); + // Animazione di entrata del modal useEffect(() => { if (visible) { @@ -199,32 +209,44 @@ const VoiceChatModal: React.FC = ({ } }; - // Render dello stato - versione minimale + // Render dello stato const renderStateIndicator = () => { - if (state === 'connecting') { - return Connessione in corso...; - } - - if (state === 'error') { - return Qualcosa Γ¨ andato storto; - } - - if (isRecording && isSpeechActive) { - return Ti ascolto...; - } - - if (isProcessing || isSpeaking) { - return Sto pensando...; - } - - if (isConnected && !isRecording) { - return Parla quando vuoi; + switch (state) { + case 'connecting': + case 'authenticating': + return Connessione in corso...; + case 'setting_up': + return Preparazione assistente...; + case 'error': + return Qualcosa Γ¨ andato storto; + case 'recording': + return {isSpeechActive ? 'Ti ascolto...' : 'Parla quando vuoi'}; + case 'processing': + if (activeTools.some(t => t.status === 'running')) { + return Sto eseguendo azioni...; + } + return Sto pensando...; + case 'speaking': + return Rispondo...; + case 'ready': + return Parla quando vuoi; + default: + return null; } + }; - return null; + // Mostra l'ultima trascrizione + const renderLastTranscript = () => { + if (transcripts.length === 0) return null; + const last = transcripts[transcripts.length - 1]; + return ( + + {last.role === 'user' ? 'Tu: ' : ''}{last.content} + + ); }; - // Render del pulsante principale - versione minimale + // Render del pulsante principale const renderMainButton = () => { // Stato: elaborazione o risposta in corso if (isProcessing || isSpeaking) { @@ -239,8 +261,8 @@ const VoiceChatModal: React.FC = ({ ); } - // Stato: connessione - if (state === 'connecting') { + // Stato: connessione / setup + if (state === 'connecting' || state === 'authenticating' || state === 'setting_up') { return ( @@ -305,8 +327,7 @@ const VoiceChatModal: React.FC = ({ { - if (isSpeaking) stopPlayback(); - if (isProcessing) sendControl('cancel'); + sendInterrupt(); }} activeOpacity={0.7} > @@ -388,6 +409,9 @@ const VoiceChatModal: React.FC = ({ {/* Pulsante stop durante elaborazione */} {renderStopButton()} + {/* Ultima trascrizione */} + {renderLastTranscript()} + {/* Messaggio di errore minimalista */} {error && ( @@ -450,6 +474,15 @@ const styles = StyleSheet.create({ marginBottom: 52, fontFamily: "System", }, + transcriptText: { + fontSize: 13, + fontWeight: "300", + color: "rgba(255, 255, 255, 0.4)", + textAlign: "center", + marginTop: 32, + maxWidth: "85%", + fontFamily: "System", + }, microphoneContainer: { position: "relative", alignItems: "center", diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index ab65be5..a12b5fc 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -1,20 +1,21 @@ import { useState, useRef, useCallback, useEffect } from 'react'; -import { VoiceBotWebSocket, VoiceChatCallbacks } from '../services/botservice'; +import { VoiceBotWebSocket, VoiceChatCallbacks, VoiceServerPhase } from '../services/botservice'; import { AudioRecorder, AudioPlayer, checkAudioPermissions, VADCallbacks } from '../utils/audioUtils'; -import { debugAudioDependencies } from '../utils/audioDebug'; /** * Stati possibili della chat vocale */ -export type VoiceChatState = - | 'idle' // Inattivo - | 'connecting' // Connessione in corso - | 'connected' // Connesso e pronto - | 'recording' // Registrazione audio utente - | 'processing' // Elaborazione server (trascrizione/IA) - | 'speaking' // Riproduzione risposta bot - | 'error' // Stato di errore - | 'disconnected'; // Disconnesso +export type VoiceChatState = + | 'idle' // Inattivo + | 'connecting' // Connessione WebSocket in corso + | 'authenticating' // Autenticazione in corso + | 'setting_up' // Server sta configurando MCP + RealtimeAgent + | 'ready' // Pronto per ricevere input + | 'recording' // Registrazione audio utente + | 'processing' // Agent sta elaborando + | 'speaking' // Riproduzione risposta audio + | 'error' // Stato di errore + | 'disconnected'; // Disconnesso /** * Informazioni sullo stato del server @@ -24,9 +25,26 @@ export interface ServerStatus { message: string; } +/** + * Trascrizione di un messaggio vocale + */ +export interface VoiceTranscript { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Tool in esecuzione + */ +export interface ActiveTool { + name: string; + status: 'running' | 'complete'; + output?: string; +} + /** * Hook personalizzato per la gestione della chat vocale - * Integra WebSocket, registrazione audio, e riproduzione + * Compatibile con l'OpenAI Realtime API tramite WebSocket */ export function useVoiceChat() { // Stati principali @@ -35,25 +53,22 @@ export function useVoiceChat() { const [serverStatus, setServerStatus] = useState(null); const [recordingDuration, setRecordingDuration] = useState(0); const [hasPermissions, setHasPermissions] = useState(false); + const [chunksReceived, setChunksReceived] = useState(0); + + // Trascrizioni e tool + const [transcripts, setTranscripts] = useState([]); + const [activeTools, setActiveTools] = useState([]); + + // VAD states (feedback UI) + const [audioLevel, setAudioLevel] = useState(-160); + const [isSpeechActive, setIsSpeechActive] = useState(false); // Refs per gestire le istanze const websocketRef = useRef(null); const audioRecorderRef = useRef(null); const audioPlayerRef = useRef(null); const recordingIntervalRef = useRef(null); - const lastChunkIndexRef = useRef(null); - - const [isReceivingAudio, setIsReceivingAudio] = useState(false); - const [chunksReceived, setChunksReceived] = useState(0); - - // Chunk timing diagnostics - const lastChunkTimeRef = useRef(0); - const chunkTimingsRef = useRef([]); - - // VAD states (sempre attivo di default) - const [vadEnabled, setVadEnabled] = useState(true); - const [audioLevel, setAudioLevel] = useState(-160); - const [isSpeechActive, setIsSpeechActive] = useState(false); + const shouldAutoStartRecordingRef = useRef(false); /** * Verifica e richiede i permessi audio @@ -62,12 +77,12 @@ export function useVoiceChat() { try { const granted = await checkAudioPermissions(); setHasPermissions(granted); - + if (!granted) { setError('Permessi microfono richiesti per la chat vocale'); setState('error'); } - + return granted; } catch (err) { console.error('Errore richiesta permessi:', err); @@ -77,29 +92,49 @@ export function useVoiceChat() { } }, []); - /** - * Ref per gestire l'avvio automatico della registrazione dopo autenticazione + * VAD Callbacks β€” solo per feedback UI, il turn detection e' gestito dal server */ - const shouldAutoStartRecordingRef = useRef(false); + const vadCallbacks: VADCallbacks = { + onSpeechStart: () => { + setIsSpeechActive(true); + }, + onSpeechEnd: () => { + setIsSpeechActive(false); + }, + onSilenceDetected: () => { + setIsSpeechActive(false); + }, + onAutoStop: async () => { + // Client-side VAD rileva silenzio prolungato: ferma registrazione e invia + await stopRecording(); + }, + onMeteringUpdate: (level: number) => { + setAudioLevel(level); + }, + }; /** * Callback per gestire i messaggi WebSocket */ const websocketCallbacks: VoiceChatCallbacks = { onConnectionOpen: () => { - console.log('🎀 WebSocket connesso, in attesa di autenticazione...'); - // Non impostare 'connected' qui, aspetta l'autenticazione + setState('authenticating'); setError(null); }, onAuthenticationSuccess: (message: string) => { - console.log('βœ… Autenticazione completata:', message); - setState('connected'); + console.log('Autenticazione completata:', message); + setState('setting_up'); + // Aspetta il messaggio "ready" prima di fare qualsiasi cosa + }, - // Avvia la registrazione se richiesto + onReady: () => { + console.log('Sessione vocale pronta'); + setState('ready'); + + // Avvia la registrazione automaticamente se richiesto if (shouldAutoStartRecordingRef.current) { - console.log('🎀 Avvio registrazione automatica post-autenticazione...'); shouldAutoStartRecordingRef.current = false; setTimeout(() => { startRecording(); @@ -107,134 +142,89 @@ export function useVoiceChat() { } }, - onAuthenticationFailed: (error: string) => { - console.error('❌ Autenticazione fallita:', error); - setError(`Autenticazione fallita: ${error}`); + onAuthenticationFailed: (errorMsg: string) => { + console.error('Autenticazione fallita:', errorMsg); + setError(`Autenticazione fallita: ${errorMsg}`); setState('error'); }, onConnectionClose: () => { - console.log('🎀 WebSocket disconnesso'); setState('disconnected'); shouldAutoStartRecordingRef.current = false; }, - onStatus: (phase: string, message: string) => { - console.log(`πŸ“‘ Status Server: ${phase} - ${message}`); + onStatus: (phase: VoiceServerPhase, message: string) => { setServerStatus({ phase, message }); switch (phase) { - case 'receiving_audio': - // Audio ricevuto dal server - console.log('πŸ“₯ Server sta ricevendo audio...'); - lastChunkIndexRef.current = null; - break; - case 'transcription': - case 'transcription_complete': - case 'ai_processing': - case 'ai_complete': - case 'tts_generation': - case 'tts_complete': + case 'agent_start': setState('processing'); break; - case 'audio_streaming': - setIsReceivingAudio(true); - setChunksReceived(0); - lastChunkIndexRef.current = null; - if (audioPlayerRef.current) { - audioPlayerRef.current.clearChunks(); - } - setState('speaking'); - break; - case 'complete': - console.log('βœ… Pipeline completa!'); - setIsReceivingAudio(false); - lastChunkIndexRef.current = null; - // Reset chunk timing per prossimo ciclo - lastChunkTimeRef.current = 0; - chunkTimingsRef.current = []; + case 'agent_end': + // Agent ha finito di elaborare, l'audio potrebbe seguire + break; + case 'audio_end': + // Server ha finito di inviare chunk audio -> riproduci if (audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0) { - const bufferedCount = audioPlayerRef.current.getBufferedChunksCount(); - console.log(`πŸ”Š Ricevuti ${bufferedCount} chunk totali. Avvio riproduzione sequenziale...`); - setState('speaking'); - - // Riproduci i chunk uno dopo l'altro (sequenzialmente) - // Questo evita problemi di concatenazione MP3 - audioPlayerRef.current.playChunksSequentially(() => { - console.log('πŸ”Š Riproduzione completata, riavvio registrazione...'); - setState('connected'); - // Riavvia automaticamente la registrazione per la prossima domanda + audioPlayerRef.current.playPcm16Chunks(() => { + setState('ready'); + // Riavvia automaticamente la registrazione per il prossimo turno setTimeout(() => { startRecording(); - }, 500); + }, 300); }); } else { - console.log('⚠️ Nessun chunk audio ricevuto, riavvio registrazione...'); - setState('connected'); - // Riavvia automaticamente la registrazione anche se non ci sono chunk + setState('ready'); setTimeout(() => { startRecording(); - }, 500); + }, 300); } break; - } - }, - - onAudioChunk: (audioData: string, chunkIndex?: number) => { - const currentTime = Date.now(); - - - // Traccia timing inter-arrival - if (lastChunkTimeRef.current > 0) { - const interArrivalMs = currentTime - lastChunkTimeRef.current; - chunkTimingsRef.current.push(interArrivalMs); - if (interArrivalMs < 10) { - console.warn(`πŸ”Š ⚑ Chunk burst: ${interArrivalMs}ms tra chunk #${(lastChunkIndexRef.current ?? -1) + 1} e #${chunkIndex}`); - } + case 'interrupted': + // Risposta interrotta, torna pronto + if (audioPlayerRef.current) { + audioPlayerRef.current.stopPlayback(); + audioPlayerRef.current.clearChunks(); + } + setState('ready'); + break; } - lastChunkTimeRef.current = currentTime; - - console.log(`πŸ”Š Ricevuto chunk audio ${typeof chunkIndex === 'number' ? `#${chunkIndex}` : '(senza indice)'}`); + }, - if (!audioPlayerRef.current) { - console.error('πŸ”Š AudioPlayer non inizializzato'); - return; + onAudioChunk: (audioData: string, chunkIndex: number) => { + if (audioPlayerRef.current) { + audioPlayerRef.current.addChunk(audioData, chunkIndex); + setChunksReceived(prev => prev + 1); } + }, - if (typeof chunkIndex === 'number') { - const previousIndex = lastChunkIndexRef.current; + onTranscript: (role: 'user' | 'assistant', content: string) => { + setTranscripts(prev => [...prev, { role, content }]); + }, - if (previousIndex !== null) { - if (chunkIndex === previousIndex) { - console.warn(`πŸ”Š Chunk duplicato #${chunkIndex} ricevuto dal server`); - } else if (chunkIndex < previousIndex) { - console.warn(`πŸ”Š Chunk fuori ordine: #${chunkIndex} ricevuto dopo #${previousIndex}`); - } else if (chunkIndex > previousIndex + 1) { - console.warn(`πŸ”Š Mancano ${chunkIndex - previousIndex - 1} chunk prima di #${chunkIndex}`); - } - } else if (chunkIndex > 0) { - console.warn(`πŸ”Š Primo chunk ricevuto con indice ${chunkIndex} (atteso 0)`); - } - } + onToolStart: (toolName: string) => { + setActiveTools(prev => [...prev, { name: toolName, status: 'running' }]); + }, - const stored = audioPlayerRef.current.addChunk(audioData, chunkIndex); + onToolEnd: (toolName: string, output: string) => { + setActiveTools(prev => prev.map(t => + t.name === toolName && t.status === 'running' + ? { ...t, status: 'complete' as const, output } + : t + )); + }, - if (stored) { - if (typeof chunkIndex === 'number') { - lastChunkIndexRef.current = lastChunkIndexRef.current === null - ? chunkIndex - : Math.max(lastChunkIndexRef.current, chunkIndex); - } - setChunksReceived(prev => prev + 1); - } + onDone: () => { + console.log('Sessione vocale terminata dal server'); + setState('disconnected'); }, onError: (errorMessage: string) => { - console.error('🎀 Errore WebSocket:', errorMessage); + console.error('Errore WebSocket:', errorMessage); setError(errorMessage); setState('error'); } @@ -245,21 +235,14 @@ export function useVoiceChat() { */ const initialize = useCallback(async (): Promise => { try { - debugAudioDependencies(); - const permissionsGranted = await requestPermissions(); - if (!permissionsGranted) { - return false; - } + if (!permissionsGranted) return false; audioRecorderRef.current = new AudioRecorder(); audioPlayerRef.current = new AudioPlayer(); - websocketRef.current = new VoiceBotWebSocket(websocketCallbacks); - console.log('🎀 Componenti audio inizializzati'); return true; - } catch (err) { console.error('Errore inizializzazione:', err); setError('Errore durante l\'inizializzazione'); @@ -269,7 +252,7 @@ export function useVoiceChat() { }, [requestPermissions]); /** - * Connette al servizio vocale e avvia automaticamente la registrazione + * Connette al servizio vocale */ const connect = useCallback(async (): Promise => { if (!websocketRef.current) { @@ -279,55 +262,23 @@ export function useVoiceChat() { setState('connecting'); setError(null); + setTranscripts([]); + setActiveTools([]); + setChunksReceived(0); + shouldAutoStartRecordingRef.current = true; try { - console.log('πŸ”Œ CONNECT: Connessione WebSocket in corso...'); const connected = await websocketRef.current!.connect(); - if (!connected) { - console.error('❌ CONNECT: Connessione fallita'); setError('Impossibile connettersi al servizio vocale'); setState('error'); - return false; - } - - console.log('βœ… CONNECT: WebSocket connesso, attesa autenticazione...'); - - // Imposta il flag per avviare automaticamente la registrazione dopo l'autenticazione - shouldAutoStartRecordingRef.current = true; - - // Aspetta che il WebSocket sia autenticato (non solo connesso) - let retries = 0; - const maxRetries = 30; // 3 secondi max per autenticazione - - while (!websocketRef.current.isAuthenticated() && retries < maxRetries) { - console.log(`⏳ CONNECT: Attesa autenticazione... (${retries + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, 100)); - retries++; - - // Se il WebSocket si Γ¨ disconnesso durante l'attesa, esci - if (!websocketRef.current.isConnected()) { - console.error('❌ CONNECT: WebSocket disconnesso durante autenticazione'); - setError('WebSocket disconnesso'); - setState('error'); - shouldAutoStartRecordingRef.current = false; - return false; - } - } - - if (!websocketRef.current.isAuthenticated()) { - console.error('❌ CONNECT: Timeout autenticazione WebSocket'); - setError('Timeout autenticazione'); - setState('error'); shouldAutoStartRecordingRef.current = false; return false; } - - console.log('βœ… CONNECT: Autenticazione completata! Registrazione verrΓ  avviata automaticamente...'); + // Le transizioni di stato avvengono via callback: + // connecting -> authenticating -> setting_up -> ready return true; - } catch (err) { - console.error('❌ CONNECT: Errore connessione:', err); setError('Errore di connessione'); setState('error'); shouldAutoStartRecordingRef.current = false; @@ -335,80 +286,27 @@ export function useVoiceChat() { } }, [initialize]); - /** - * VAD Callbacks - */ - const vadCallbacks: VADCallbacks = { - onSpeechStart: () => { - console.log('πŸŽ™οΈ HOOK: βœ… Inizio voce rilevato - UI aggiornata'); - setIsSpeechActive(true); - }, - onSpeechEnd: () => { - console.log('πŸŽ™οΈ HOOK: ⏹️ Fine voce rilevata - UI aggiornata'); - setIsSpeechActive(false); - }, - onSilenceDetected: () => { - console.log('πŸŽ™οΈ HOOK: πŸ”‡ Silenzio rilevato - Timer avviato'); - }, - onAutoStop: async () => { - console.log('πŸŽ™οΈ HOOK: πŸ›‘ Auto-stop chiamato - Fermando registrazione...'); - await stopRecording(); - }, - onMeteringUpdate: (level: number) => { - setAudioLevel(level); - // Log dettagliato del livello solo ogni secondo (invece di ogni 100ms) - if (Date.now() % 1000 < 150) { - console.log(`🎚️ HOOK: Audio level aggiornato β†’ ${level.toFixed(1)} dB`); - } - }, - }; - - /** - * Toggle VAD mode - */ - const toggleVAD = useCallback(() => { - setVadEnabled(prev => !prev); - }, []); - /** * Avvia la registrazione audio */ const startRecording = useCallback(async (): Promise => { - console.log('🎬 START RECORDING: Chiamata startRecording()'); - if (!audioRecorderRef.current || !websocketRef.current) { - console.error('❌ START RECORDING: Servizio non inizializzato'); - console.log(' - audioRecorderRef:', !!audioRecorderRef.current); - console.log(' - websocketRef:', !!websocketRef.current); setError('Servizio non inizializzato'); return false; } - if (!websocketRef.current.isConnected()) { - console.error('❌ START RECORDING: WebSocket non connesso'); - setError('WebSocket non connesso'); + if (!websocketRef.current.isReady()) { + setError('Sessione vocale non pronta'); return false; } - if (!websocketRef.current.isAuthenticated()) { - console.error('❌ START RECORDING: WebSocket non autenticato'); - setError('WebSocket non autenticato'); - return false; - } - - console.log('βœ… START RECORDING: Pre-check OK (connesso e autenticato), avvio registrazione...'); - try { - const started = await audioRecorderRef.current.startRecording(vadEnabled, vadCallbacks); - console.log('πŸ“ START RECORDING: Risultato startRecording():', started); - + const started = await audioRecorderRef.current.startRecording(true, vadCallbacks); if (!started) { - console.error('❌ START RECORDING: Impossibile avviare la registrazione'); setError('Impossibile avviare la registrazione'); return false; } - console.log('βœ… START RECORDING: Registrazione avviata con successo!'); setState('recording'); setError(null); setIsSpeechActive(false); @@ -417,29 +315,24 @@ export function useVoiceChat() { // Aggiorna la durata della registrazione ogni 100ms recordingIntervalRef.current = setInterval(() => { if (audioRecorderRef.current) { - const duration = audioRecorderRef.current.getRecordingDuration(); - setRecordingDuration(duration); + setRecordingDuration(audioRecorderRef.current.getRecordingDuration()); } }, 100); - console.log('🎀 Registrazione avviata', vadEnabled ? '(VAD attivo)' : '(VAD disattivo)'); return true; - } catch (err) { - console.error('❌ START RECORDING: Errore avvio registrazione:', err); + console.error('Errore avvio registrazione:', err); setError('Errore durante la registrazione'); setState('error'); return false; } - }, [vadEnabled]); + }, []); /** * Ferma la registrazione e invia l'audio al server */ const stopRecording = useCallback(async (): Promise => { - if (!audioRecorderRef.current || !websocketRef.current) { - return false; - } + if (!audioRecorderRef.current || !websocketRef.current) return false; // Ferma il timer della durata if (recordingIntervalRef.current) { @@ -448,23 +341,22 @@ export function useVoiceChat() { } try { - const audioData = await audioRecorderRef.current.stopRecording(); - if (!audioData) { + const pcm16Base64 = await audioRecorderRef.current.stopRecording(); + if (!pcm16Base64) { setError('Nessun dato audio registrato'); setState('error'); return false; } - console.log('🎀 Invio audio al server...'); - - // Invia l'audio al server tramite WebSocket - websocketRef.current.sendAudioChunk(audioData, true); - + // Invia audio PCM16 e committa + websocketRef.current.sendAudio(pcm16Base64); + websocketRef.current.sendAudioCommit(); + setState('processing'); setRecordingDuration(0); - + setIsSpeechActive(false); return true; - + } catch (err) { console.error('Errore stop registrazione:', err); setError('Errore durante l\'invio dell\'audio'); @@ -487,8 +379,8 @@ export function useVoiceChat() { } setRecordingDuration(0); - setState('connected'); - console.log('🎀 Registrazione cancellata'); + setIsSpeechActive(false); + setState('ready'); }, []); /** @@ -497,19 +389,33 @@ export function useVoiceChat() { const stopPlayback = useCallback(async (): Promise => { if (audioPlayerRef.current) { await audioPlayerRef.current.stopPlayback(); + audioPlayerRef.current.clearChunks(); + } + + setState('ready'); + }, []); + + /** + * Interrompe la risposta corrente dell'assistente + */ + const sendInterrupt = useCallback((): void => { + if (websocketRef.current?.isConnected()) { + websocketRef.current.sendInterrupt(); + } + if (audioPlayerRef.current) { + audioPlayerRef.current.stopPlayback(); + audioPlayerRef.current.clearChunks(); } - - setState('connected'); - console.log('πŸ”Š Riproduzione fermata'); + setState('ready'); }, []); /** - * Invia comando di controllo al server + * Invia un messaggio di testo all'assistente */ - const sendControl = useCallback((action: 'pause' | 'resume' | 'cancel'): void => { - if (websocketRef.current && websocketRef.current.isConnected()) { - websocketRef.current.sendControl(action); - console.log(`🎀 Comando inviato: ${action}`); + const sendTextMessage = useCallback((content: string): void => { + if (websocketRef.current?.isReady()) { + websocketRef.current.sendText(content); + setState('processing'); } }, []); @@ -520,44 +426,37 @@ export function useVoiceChat() { if (websocketRef.current) { websocketRef.current.disconnect(); } - + setState('idle'); setServerStatus(null); setError(null); - console.log('🎀 Disconnesso dal servizio'); + setTranscripts([]); + setActiveTools([]); }, []); /** * Pulisce tutte le risorse */ const cleanup = useCallback(async (): Promise => { - // Ferma registrazione se attiva if (audioRecorderRef.current) { await audioRecorderRef.current.cancelRecording(); } - - // Ferma riproduzione se attiva if (audioPlayerRef.current) { await audioPlayerRef.current.destroy(); } - - // Disconnetti WebSocket if (websocketRef.current) { websocketRef.current.destroy(); } - - // Pulisci timer if (recordingIntervalRef.current) { clearInterval(recordingIntervalRef.current); } - // Reset stati setState('idle'); setError(null); setServerStatus(null); setRecordingDuration(0); - - console.log('🎀 Risorse pulite'); + setTranscripts([]); + setActiveTools([]); }, []); // Cleanup automatico quando il componente viene smontato @@ -567,12 +466,12 @@ export function useVoiceChat() { }; }, [cleanup]); - // Stati derivati per convenience - const isConnected = state === 'connected' || state === 'recording' || state === 'processing' || state === 'speaking'; + // Stati derivati + const isConnected = ['ready', 'recording', 'processing', 'speaking'].includes(state); const isRecording = state === 'recording'; const isProcessing = state === 'processing'; const isSpeaking = state === 'speaking'; - const canRecord = state === 'connected' && hasPermissions; + const canRecord = state === 'ready' && hasPermissions; const canStop = state === 'recording'; return { @@ -584,6 +483,10 @@ export function useVoiceChat() { hasPermissions, chunksReceived, + // Trascrizioni e tool + transcripts, + activeTools, + // Stati derivati isConnected, isRecording, @@ -593,7 +496,6 @@ export function useVoiceChat() { canStop, // VAD stati - vadEnabled, audioLevel, isSpeechActive, @@ -605,9 +507,9 @@ export function useVoiceChat() { stopRecording, cancelRecording, stopPlayback, - sendControl, + sendInterrupt, + sendTextMessage, cleanup, requestPermissions, - toggleVAD, }; } diff --git a/src/services/botservice.ts b/src/services/botservice.ts index 10947e8..f7169c4 100644 --- a/src/services/botservice.ts +++ b/src/services/botservice.ts @@ -452,65 +452,135 @@ export function extractStructuredData(response: string): any { } } -// ============= VOICE CHAT WEBSOCKET ============= +// ============= VOICE CHAT WEBSOCKET (OpenAI Realtime API) ============= /** - * Tipi per i messaggi WebSocket della chat vocale + * Tipi per i messaggi client -> server */ -export interface VoiceWebSocketMessage { - type: 'auth' | 'audio_chunk' | 'control'; - token?: string; - data?: string; // base64 audio data - is_final?: boolean; - action?: 'pause' | 'resume' | 'cancel'; +export interface VoiceAuthMessage { + type: 'auth'; + token: string; } -export interface VoiceWebSocketResponse { - type: 'status' | 'audio_chunk' | 'error'; - phase?: 'authenticated' | 'receiving_audio' | 'transcription' | 'transcription_complete' | 'ai_processing' | 'ai_complete' | 'tts_generation' | 'tts_complete' | 'audio_streaming' | 'complete'; +export interface VoiceAudioMessage { + type: 'audio'; + data: string; // base64 PCM16 +} + +export interface VoiceAudioCommitMessage { + type: 'audio_commit'; +} + +export interface VoiceTextMessage { + type: 'text'; + content: string; +} + +export interface VoiceInterruptMessage { + type: 'interrupt'; +} + +export type VoiceClientMessage = + | VoiceAuthMessage + | VoiceAudioMessage + | VoiceAudioCommitMessage + | VoiceTextMessage + | VoiceInterruptMessage; + +/** + * Tipi per i messaggi server -> client + */ +export type VoiceServerPhase = + | 'authenticated' + | 'ready' + | 'interrupted' + | 'audio_end' + | 'agent_start' + | 'agent_end'; + +export interface VoiceStatusResponse { + type: 'status'; + phase: VoiceServerPhase; message?: string; - data?: string; // base64 audio data - chunk_index?: number; } +export interface VoiceAudioResponse { + type: 'audio'; + data: string; // base64 PCM16 + chunk_index: number; +} + +export interface VoiceTranscriptResponse { + type: 'transcript'; + role: 'user' | 'assistant'; + content: string; +} + +export interface VoiceToolStartResponse { + type: 'tool_start'; + tool_name: string; + arguments: string; +} + +export interface VoiceToolEndResponse { + type: 'tool_end'; + tool_name: string; + output: string; +} + +export interface VoiceErrorResponse { + type: 'error'; + message: string; +} + +export interface VoiceDoneResponse { + type: 'done'; +} + +export type VoiceServerMessage = + | VoiceStatusResponse + | VoiceAudioResponse + | VoiceTranscriptResponse + | VoiceToolStartResponse + | VoiceToolEndResponse + | VoiceErrorResponse + | VoiceDoneResponse; + /** - * Stati di autenticazione WebSocket + * Stati di autenticazione/connessione WebSocket */ export enum WebSocketAuthState { DISCONNECTED = 'disconnected', CONNECTING = 'connecting', AUTHENTICATING = 'authenticating', AUTHENTICATED = 'authenticated', + READY = 'ready', FAILED = 'failed' } -/** - * Interfaccia per messaggi in coda prima dell'autenticazione - */ -interface QueuedMessage { - type: 'audio_chunk' | 'control'; - data?: string; - is_final?: boolean; - action?: 'pause' | 'resume' | 'cancel'; -} - /** * Callback per gestire i diversi tipi di risposta dal WebSocket vocale */ export interface VoiceChatCallbacks { - onStatus?: (phase: string, message: string) => void; - onAudioChunk?: (audioData: string, chunkIndex?: number) => void; + onStatus?: (phase: VoiceServerPhase, message: string) => void; + onAudioChunk?: (audioData: string, chunkIndex: number) => void; + onTranscript?: (role: 'user' | 'assistant', content: string) => void; + onToolStart?: (toolName: string, args: string) => void; + onToolEnd?: (toolName: string, output: string) => void; onError?: (error: string) => void; onConnectionOpen?: () => void; onConnectionClose?: () => void; onAuthenticationSuccess?: (message: string) => void; onAuthenticationFailed?: (error: string) => void; + onReady?: () => void; + onDone?: () => void; } -const MAX_AUDIO_CHUNK_BYTES = 2_500_000; // ~2.5MB di audio PCM/mp3 +const MAX_AUDIO_CHUNK_BYTES = 2_500_000; /** * Classe per gestire la connessione WebSocket per la chat vocale + * Compatibile con l'OpenAI Realtime API tramite il backend */ export class VoiceBotWebSocket { private ws: WebSocket | null = null; @@ -520,9 +590,9 @@ export class VoiceBotWebSocket { private maxReconnectAttempts: number = 3; private reconnectDelay: number = 1000; private authState: WebSocketAuthState = WebSocketAuthState.DISCONNECTED; - private messageQueue: QueuedMessage[] = []; + private messageQueue: VoiceClientMessage[] = []; private authTimeout: NodeJS.Timeout | null = null; - private readonly AUTH_TIMEOUT_MS = 10000; // 10 secondi timeout per autenticazione + private readonly AUTH_TIMEOUT_MS = 15000; // 15s timeout (setup MCP + RealtimeAgent) constructor(callbacks: VoiceChatCallbacks) { this.callbacks = callbacks; @@ -543,11 +613,9 @@ export class VoiceBotWebSocket { this.authState = WebSocketAuthState.CONNECTING; const wsUrl = `${this.baseUrl}/chat/voice-bot-websocket`; - // Crea una Promise che si risolve quando onopen viene chiamato return new Promise((resolve, reject) => { this.ws = new WebSocket(wsUrl); - // Timeout per la connessione (10 secondi) const connectionTimeout = setTimeout(() => { this.authState = WebSocketAuthState.FAILED; this.callbacks.onError?.('Timeout connessione WebSocket'); @@ -556,10 +624,7 @@ export class VoiceBotWebSocket { this.ws.onopen = () => { clearTimeout(connectionTimeout); - console.log('🎀 Connessione WebSocket vocale aperta'); this.reconnectAttempts = 0; - - // Invia autenticazione e avvia timeout this.startAuthentication(token); this.callbacks.onConnectionOpen?.(); resolve(true); @@ -567,7 +632,7 @@ export class VoiceBotWebSocket { this.ws.onmessage = (event) => { try { - const response: VoiceWebSocketResponse = JSON.parse(event.data); + const response = JSON.parse(event.data); this.handleResponse(response); } catch (error) { console.error('Errore parsing risposta WebSocket:', error); @@ -586,13 +651,11 @@ export class VoiceBotWebSocket { this.ws.onclose = (event) => { clearTimeout(connectionTimeout); - console.log('🎀 Connessione WebSocket vocale chiusa:', event.code, event.reason); this.authState = WebSocketAuthState.DISCONNECTED; this.clearAuthTimeout(); - this.clearMessageQueue(); + this.messageQueue = []; this.callbacks.onConnectionClose?.(); - // Tentativo di riconnessione automatica if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { this.attemptReconnect(); } @@ -614,36 +677,20 @@ export class VoiceBotWebSocket { this.authState = WebSocketAuthState.AUTHENTICATING; - // Avvia timeout per autenticazione this.authTimeout = setTimeout(() => { - this.handleAuthenticationTimeout(); + this.authState = WebSocketAuthState.FAILED; + this.callbacks.onAuthenticationFailed?.('Timeout autenticazione - il server non ha risposto'); + this.disconnect(); }, this.AUTH_TIMEOUT_MS); - // Assicurati che il token abbia il prefisso "Bearer " - const formattedToken = token.startsWith('Bearer ') ? token : `Bearer ${token}`; - - const authMessage: VoiceWebSocketMessage = { + const authMessage: VoiceAuthMessage = { type: 'auth', - token: formattedToken + token: token.startsWith('Bearer ') ? token : `Bearer ${token}` }; - console.log('πŸ” Invio autenticazione JWT'); this.ws!.send(JSON.stringify(authMessage)); } - /** - * Gestisce il timeout dell'autenticazione - */ - private handleAuthenticationTimeout(): void { - console.error('⏰ Timeout autenticazione WebSocket'); - this.authState = WebSocketAuthState.FAILED; - this.callbacks.onAuthenticationFailed?.('Timeout autenticazione - il server non ha risposto'); - this.disconnect(); - } - - /** - * Pulisce il timeout di autenticazione - */ private clearAuthTimeout(): void { if (this.authTimeout) { clearTimeout(this.authTimeout); @@ -654,8 +701,7 @@ export class VoiceBotWebSocket { /** * Gestisce le risposte ricevute dal WebSocket */ - private handleResponse(response: VoiceWebSocketResponse): void { - // Validazione sicurezza messaggio + private handleResponse(response: VoiceServerMessage): void { if (!this.validateResponse(response)) { console.warn('Messaggio WebSocket non valido ricevuto:', response); return; @@ -663,90 +709,103 @@ export class VoiceBotWebSocket { switch (response.type) { case 'status': - this.handleStatusResponse(response); + this.handleStatusResponse(response as VoiceStatusResponse); break; - case 'audio_chunk': - this.handleAudioChunkResponse(response); + case 'audio': + this.handleAudioResponse(response as VoiceAudioResponse); + break; + + case 'transcript': + this.handleTranscriptResponse(response as VoiceTranscriptResponse); + break; + + case 'tool_start': + this.callbacks.onToolStart?.( + (response as VoiceToolStartResponse).tool_name, + (response as VoiceToolStartResponse).arguments + ); + break; + + case 'tool_end': + this.callbacks.onToolEnd?.( + (response as VoiceToolEndResponse).tool_name, + (response as VoiceToolEndResponse).output + ); break; case 'error': - this.handleErrorResponse(response); + this.handleErrorResponse(response as VoiceErrorResponse); break; - default: - console.warn('Tipo di risposta WebSocket sconosciuto:', response.type); + case 'done': + this.callbacks.onDone?.(); + break; } } /** - * Gestisce risposta di stato (inclusa autenticazione) + * Gestisce risposta di stato */ - private handleStatusResponse(response: VoiceWebSocketResponse): void { - if (!response.phase || !response.message) return; + private handleStatusResponse(response: VoiceStatusResponse): void { + const phase = response.phase; + const message = response.message || ''; - switch (response.phase) { + switch (phase) { case 'authenticated': - this.handleAuthenticationSuccess(response.message); + console.log('Autenticazione WebSocket riuscita:', message); + this.authState = WebSocketAuthState.AUTHENTICATED; + this.callbacks.onAuthenticationSuccess?.(message); + this.callbacks.onStatus?.(phase, message); + // Non processare la coda qui - aspettare 'ready' + break; + + case 'ready': + console.log('Sessione vocale pronta'); + this.authState = WebSocketAuthState.READY; + this.clearAuthTimeout(); + this.processQueuedMessages(); + this.callbacks.onReady?.(); + this.callbacks.onStatus?.(phase, message); break; - case 'receiving_audio': - case 'transcription': - case 'transcription_complete': - case 'ai_processing': - case 'ai_complete': - case 'tts_generation': - case 'tts_complete': - case 'audio_streaming': - case 'complete': - this.callbacks.onStatus?.(response.phase, response.message); + case 'interrupted': + case 'audio_end': + case 'agent_start': + case 'agent_end': + this.callbacks.onStatus?.(phase, message); break; default: - console.warn('Fase WebSocket sconosciuta:', response.phase); + console.warn('Fase WebSocket sconosciuta:', phase); } } /** - * Gestisce il successo dell'autenticazione + * Gestisce risposta audio PCM16 */ - private handleAuthenticationSuccess(message: string): void { - console.log('βœ… Autenticazione WebSocket riuscita:', message); - this.authState = WebSocketAuthState.AUTHENTICATED; - this.clearAuthTimeout(); - - // Processa messaggi in coda - this.processQueuedMessages(); + private handleAudioResponse(response: VoiceAudioResponse): void { + if (this.authState === WebSocketAuthState.DISCONNECTED) return; - this.callbacks.onAuthenticationSuccess?.(message); - this.callbacks.onStatus?.('authenticated', message); + if (response.data) { + this.callbacks.onAudioChunk?.(response.data, response.chunk_index); + } } /** - * Gestisce risposta audio chunk + * Gestisce risposta di trascrizione */ - private handleAudioChunkResponse(response: VoiceWebSocketResponse): void { - // Permetti audio chunks anche durante l'autenticazione - // Il server potrebbe iniziare a inviare dati prima della conferma ufficiale - if (this.authState === WebSocketAuthState.DISCONNECTED) { - console.warn('Ricevuto audio chunk senza connessione'); - return; - } - - if (response.data) { - this.callbacks.onAudioChunk?.(response.data, response.chunk_index); - } + private handleTranscriptResponse(response: VoiceTranscriptResponse): void { + this.callbacks.onTranscript?.(response.role, response.content); } /** * Gestisce risposta di errore */ - private handleErrorResponse(response: VoiceWebSocketResponse): void { + private handleErrorResponse(response: VoiceErrorResponse): void { if (!response.message) return; - // Gestione errori specifici di autenticazione if (this.authState === WebSocketAuthState.AUTHENTICATING) { - console.error('❌ Errore autenticazione:', response.message); this.authState = WebSocketAuthState.FAILED; this.clearAuthTimeout(); this.callbacks.onAuthenticationFailed?.(response.message); @@ -756,176 +815,124 @@ export class VoiceBotWebSocket { } /** - * Invia un chunk audio al server - */ - sendAudioChunk(base64AudioData: string, isFinal: boolean = false): void { - if (!this.isConnected()) { - this.callbacks.onError?.('Connessione WebSocket non disponibile'); - return; - } - - const audioMessage: QueuedMessage = { - type: 'audio_chunk', - data: base64AudioData, - is_final: isFinal - }; - - // Se non autenticato, metti in coda il messaggio - if (!this.isAuthenticated()) { - console.log('πŸ”’ Messaggio audio messo in coda (non autenticato)'); - this.messageQueue.push(audioMessage); - return; - } - - this.sendMessage(audioMessage); - } - - /** - * Invia comandi di controllo (pause, resume, cancel) + * Invia un chunk audio PCM16 base64 al server */ - sendControl(action: 'pause' | 'resume' | 'cancel'): void { - if (!this.isConnected()) { - this.callbacks.onError?.('Connessione WebSocket non disponibile'); - return; - } - - const controlMessage: QueuedMessage = { - type: 'control', - action: action - }; - - // I comandi di controllo possono essere inviati anche senza autenticazione - // per permettere di cancellare operazioni in corso - if (!this.isAuthenticated() && action !== 'cancel') { - console.log('πŸ”’ Comando di controllo messo in coda (non autenticato)'); - this.messageQueue.push(controlMessage); - return; - } - - this.sendMessage(controlMessage); + sendAudio(base64Pcm16Data: string): void { + const msg: VoiceAudioMessage = { type: 'audio', data: base64Pcm16Data }; + this.sendOrQueue(msg); } /** - * Controlla se la connessione WebSocket Γ¨ attiva + * Committa il buffer audio (opzionale - il server ha semantic VAD) */ - isConnected(): boolean { - return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + sendAudioCommit(): void { + const msg: VoiceAudioCommitMessage = { type: 'audio_commit' }; + this.sendOrQueue(msg); } /** - * Controlla se l'utente Γ¨ autenticato + * Invia un messaggio di testo all'assistente */ - isAuthenticated(): boolean { - return this.authState === WebSocketAuthState.AUTHENTICATED; + sendText(content: string): void { + const msg: VoiceTextMessage = { type: 'text', content }; + this.sendOrQueue(msg); } /** - * Ottiene lo stato di autenticazione corrente + * Interrompe la risposta corrente dell'assistente */ - getAuthState(): WebSocketAuthState { - return this.authState; + sendInterrupt(): void { + if (this.isConnected()) { + this.ws!.send(JSON.stringify({ type: 'interrupt' } as VoiceInterruptMessage)); + } } /** - * Invia un messaggio al WebSocket + * Invia un messaggio o lo mette in coda se non ancora pronto */ - private sendMessage(message: QueuedMessage): void { - if (!this.isConnected()) return; + private sendOrQueue(message: VoiceClientMessage): void { + if (!this.isConnected()) { + this.callbacks.onError?.('Connessione WebSocket non disponibile'); + return; + } - const wsMessage: VoiceWebSocketMessage = { - type: message.type, - data: message.data, - is_final: message.is_final, - action: message.action - }; + if (!this.isReady()) { + this.messageQueue.push(message); + return; + } - this.ws!.send(JSON.stringify(wsMessage)); + this.ws!.send(JSON.stringify(message)); } /** - * Processa i messaggi in coda dopo l'autenticazione + * Processa i messaggi in coda dopo che la sessione e' pronta */ private processQueuedMessages(): void { if (this.messageQueue.length === 0) return; - console.log(`πŸ“€ Processando ${this.messageQueue.length} messaggi in coda`); - while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); - if (message) { - this.sendMessage(message); + if (message && this.isConnected()) { + this.ws!.send(JSON.stringify(message)); } } } /** - * Pulisce la coda dei messaggi - */ - private clearMessageQueue(): void { - if (this.messageQueue.length > 0) { - console.log(`πŸ—‘οΈ Pulisco ${this.messageQueue.length} messaggi in coda`); - this.messageQueue = []; - } - } - - /** - * Valida la sicurezza di una risposta WebSocket + * Valida una risposta WebSocket */ - private validateResponse(response: VoiceWebSocketResponse): boolean { - // Controlli di sicurezza di base - if (!response || typeof response !== 'object') { - return false; - } + private validateResponse(response: any): response is VoiceServerMessage { + if (!response || typeof response !== 'object') return false; - // Verifica che il tipo sia valido - const validTypes = ['status', 'audio_chunk', 'error']; - if (!validTypes.includes(response.type)) { - return false; - } + const validTypes = ['status', 'audio', 'transcript', 'tool_start', 'tool_end', 'error', 'done']; + if (!validTypes.includes(response.type)) return false; - // Verifica lunghezza messaggi per prevenire DoS - if (response.message && response.message.length > 1000) { + // Verifica lunghezza messaggi + if (response.message && typeof response.message === 'string' && response.message.length > 5000) { console.warn('Messaggio troppo lungo ricevuto dal server'); return false; } - // Verifica chunk audio per prevenire overflow - if (response.data) { - if (response.type === 'audio_chunk') { - const approxChunkBytes = Math.floor(response.data.length * 0.75); - if (approxChunkBytes > MAX_AUDIO_CHUNK_BYTES) { - console.warn(`Chunk audio molto grande ricevuto dal server (~${approxChunkBytes} bytes)`); - } - } else if (response.data.length > 50000) { - console.warn('Payload dati troppo grande ricevuto dal server'); - return false; + // Verifica chunk audio + if (response.type === 'audio' && response.data) { + const approxBytes = Math.floor(response.data.length * 0.75); + if (approxBytes > MAX_AUDIO_CHUNK_BYTES) { + console.warn(`Chunk audio molto grande (~${approxBytes} bytes)`); } } return true; } - /** - * Tentativo di riconnessione automatica - */ + isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + isAuthenticated(): boolean { + return this.authState === WebSocketAuthState.AUTHENTICATED || + this.authState === WebSocketAuthState.READY; + } + + isReady(): boolean { + return this.authState === WebSocketAuthState.READY; + } + + getAuthState(): WebSocketAuthState { + return this.authState; + } + private attemptReconnect(): void { this.reconnectAttempts++; const delay = this.reconnectDelay * this.reconnectAttempts; - console.log(`🎀 Tentativo riconnessione ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`); - setTimeout(() => { this.connect(); }, delay); } - /** - * Disconnette il WebSocket - */ disconnect(): void { - // Pulisce timeout e risorse this.clearAuthTimeout(); - this.clearMessageQueue(); + this.messageQueue = []; this.authState = WebSocketAuthState.DISCONNECTED; if (this.ws) { @@ -934,33 +941,9 @@ export class VoiceBotWebSocket { } } - /** - * Distrugge la connessione e pulisce tutte le risorse - */ destroy(): void { this.disconnect(); this.callbacks = {}; this.reconnectAttempts = 0; } - - /** - * Forza una nuova autenticazione (utile se il token Γ¨ stato aggiornato) - */ - async reAuthenticate(): Promise { - if (!this.isConnected()) { - console.warn('Non Γ¨ possibile ri-autenticarsi: WebSocket non connesso'); - return false; - } - - const token = await getValidToken(); - if (!token) { - this.callbacks.onError?.('Token di autenticazione non disponibile per ri-autenticazione'); - return false; - } - - console.log('πŸ”„ Avvio ri-autenticazione'); - this.clearMessageQueue(); // Pulisce eventuali messaggi precedenti - this.startAuthentication(token); - return true; - } } diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index 4f07997..89e7b5a 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system'; /** * Utility per la gestione dell'audio nella chat vocale - * Include registrazione, conversione base64, e riproduzione + * Supporta il formato PCM16 a 24kHz richiesto dall'OpenAI Realtime API */ /** @@ -12,13 +12,10 @@ import * as FileSystem from 'expo-file-system'; */ function decodeBase64(base64: string): Uint8Array { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - // Rimuove eventuali caratteri non-base64 (es. newline) const sanitized = base64.replace(/[^A-Za-z0-9+/=]/g, ''); - // Padding per lunghezza non multipla di 4 (comportamento analogo a Buffer.from) const padLength = (4 - (sanitized.length % 4)) % 4; const padded = sanitized.padEnd(sanitized.length + padLength, '='); - // Calcola la lunghezza del buffer tenendo conto del padding let bufferLength = padded.length * 0.75; if (padded.endsWith('==')) bufferLength -= 2; else if (padded.endsWith('=')) bufferLength -= 1; @@ -56,7 +53,6 @@ function encodeBase64(bytes: Uint8Array): string { while (i < bytes.length) { const a = bytes[i++]; - // Traccia quanti byte veri abbiamo per questo gruppo const hasB = i < bytes.length; const b = hasB ? bytes[i++] : 0; const hasC = i < bytes.length; @@ -73,45 +69,92 @@ function encodeBase64(bytes: Uint8Array): string { return result; } -// Configurazioni audio +// Configurazioni audio per OpenAI Realtime API export const AUDIO_CONFIG = { - SAMPLE_RATE: 16000, + SAMPLE_RATE: 24000, CHANNELS: 1, BIT_DEPTH: 16, - CHUNK_DURATION: 1000, // ms - MAX_RECORDING_TIME: 60000, // 60 secondi - AUDIO_FORMAT: Audio.RecordingOptionsPresets.HIGH_QUALITY.android.extension || 'm4a' + MAX_RECORDING_TIME: 300000, // 5 minuti per sessioni conversazionali }; -// Configurazioni VAD (Voice Activity Detection) +// Configurazioni VAD (Voice Activity Detection) - solo per feedback UI +// Il server gestisce il turn detection con semantic VAD export const VAD_CONFIG = { - SPEECH_THRESHOLD_DB: -50, // dB sopra questa soglia = voce rilevata (piΓΉ sensibile) - SILENCE_THRESHOLD_DB: -60, // dB sotto questa soglia = silenzio - SILENCE_DURATION_MS: 1200, // Durata silenzio prima di fermare (1.2s) - METERING_POLL_INTERVAL_MS: 100, // Intervallo controllo livello audio (100ms) - MIN_RECORDING_DURATION_MS: 300, // Durata minima registrazione prima di VAD (300ms) + SPEECH_THRESHOLD_DB: -50, + SILENCE_THRESHOLD_DB: -60, + SILENCE_DURATION_MS: 1200, + METERING_POLL_INTERVAL_MS: 100, + MIN_RECORDING_DURATION_MS: 300, }; // Configurazione per normalizzazione audio level export const AUDIO_LEVEL_CONFIG = { - MIN_DB: -80, // Livello di silenzio tipico - MAX_DB: -10, // Livello di voce forte + MIN_DB: -80, + MAX_DB: -10, }; -// Configurazione per voice chat chunk flow control -export const VOICE_CHUNK_CONFIG = { - // Minimum chunks to buffer before starting playback - MIN_CHUNKS_BEFORE_PLAYBACK: 3, - - // Maximum wait time for chunks (ms) before starting with available - MAX_BUFFER_WAIT_MS: 2000, +/** + * Crea un header WAV per dati audio PCM16. + * Necessario perche' expo-av richiede un formato file per la riproduzione. + */ +export function createWavHeader( + pcm16DataLength: number, + sampleRate: number = 24000, + channels: number = 1, + bitsPerSample: number = 16 +): Uint8Array { + const header = new Uint8Array(44); + const view = new DataView(header.buffer); + + const byteRate = sampleRate * channels * (bitsPerSample / 8); + const blockAlign = channels * (bitsPerSample / 8); + const fileSize = 36 + pcm16DataLength; + + // "RIFF" chunk descriptor + header.set([0x52, 0x49, 0x46, 0x46], 0); // "RIFF" + view.setUint32(4, fileSize, true); + header.set([0x57, 0x41, 0x56, 0x45], 8); // "WAVE" + + // "fmt " sub-chunk + header.set([0x66, 0x6d, 0x74, 0x20], 12); // "fmt " + view.setUint32(16, 16, true); // sub-chunk size (16 for PCM) + view.setUint16(20, 1, true); // audio format (1 = PCM) + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitsPerSample, true); + + // "data" sub-chunk + header.set([0x64, 0x61, 0x74, 0x61], 36); // "data" + view.setUint32(40, pcm16DataLength, true); + + return header; +} - // Burst detection threshold (inter-arrival time < this = burst) - BURST_DETECTION_THRESHOLD_MS: 10, +/** + * Wrappa dati PCM16 raw con un header WAV. + * Restituisce un file WAV completo come Uint8Array. + */ +export function wrapPcm16InWav(pcm16Data: Uint8Array, sampleRate: number = 24000): Uint8Array { + const header = createWavHeader(pcm16Data.length, sampleRate); + const wav = new Uint8Array(header.length + pcm16Data.length); + wav.set(header, 0); + wav.set(pcm16Data, header.length); + return wav; +} - // Warning threshold for low buffer during playback - LOW_BUFFER_WARNING_THRESHOLD: 1, -}; +/** + * Rimuove l'header WAV (44 bytes) da dati audio, restituendo PCM16 raw. + */ +function stripWavHeader(bytes: Uint8Array): Uint8Array { + if (bytes.length > 44 && + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) { + return bytes.subarray(44); + } + return bytes; +} /** * Callback per eventi VAD @@ -125,7 +168,39 @@ export interface VADCallbacks { } /** - * Classe per gestire la registrazione audio + * Opzioni di registrazione per PCM16 WAV a 24kHz + */ +function getPcm16RecordingOptions(enableMetering: boolean) { + return { + isMeteringEnabled: enableMetering, + android: { + extension: '.wav', + outputFormat: 6, // DEFAULT + audioEncoder: 0, // DEFAULT + sampleRate: AUDIO_CONFIG.SAMPLE_RATE, + numberOfChannels: AUDIO_CONFIG.CHANNELS, + bitRate: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, + }, + ios: { + extension: '.wav', + outputFormat: Audio.IOSOutputFormat.LINEARPCM, + audioQuality: Audio.IOSAudioQuality.HIGH, + sampleRate: AUDIO_CONFIG.SAMPLE_RATE, + numberOfChannels: AUDIO_CONFIG.CHANNELS, + bitRate: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, + linearPCMBitDepth: AUDIO_CONFIG.BIT_DEPTH, + linearPCMIsBigEndian: false, + linearPCMIsFloat: false, + }, + web: { + mimeType: 'audio/wav', + bitsPerSecond: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, + }, + }; +} + +/** + * Classe per gestire la registrazione audio in formato PCM16 */ export class AudioRecorder { private recording: Audio.Recording | null = null; @@ -140,24 +215,16 @@ export class AudioRecorder { private isSpeechDetected: boolean = false; /** - * Inizializza e avvia la registrazione audio + * Avvia la registrazione audio in formato PCM16 WAV a 24kHz */ async startRecording(enableVAD: boolean = false, vadCallbacks?: VADCallbacks): Promise { - console.log('πŸŽ™οΈ RECORDER: startRecording chiamato, VAD:', enableVAD); - try { - // Richiedi i permessi per il microfono - console.log('πŸ” RECORDER: Richiesta permessi microfono...'); const { granted } = await Audio.requestPermissionsAsync(); - console.log('πŸ” RECORDER: Permessi microfono:', granted ? 'βœ… Concessi' : '❌ Negati'); - if (!granted) { - console.error('❌ RECORDER: Permessi microfono non concessi'); + console.error('Permessi microfono non concessi'); return false; } - // Configura la modalitΓ  audio per la registrazione - console.log('βš™οΈ RECORDER: Configurazione modalitΓ  audio...'); await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, @@ -165,52 +232,34 @@ export class AudioRecorder { shouldDuckAndroid: true, playThroughEarpieceAndroid: false, }); - console.log('βœ… RECORDER: ModalitΓ  audio configurata'); - // Crea una nuova registrazione - console.log('πŸ“Ό RECORDER: Creazione istanza Recording...'); this.recording = new Audio.Recording(); + const recordingOptions = getPcm16RecordingOptions(enableVAD); - // Configura le opzioni di registrazione con metering se VAD Γ¨ abilitato - const recordingOptions = { - ...Audio.RecordingOptionsPresets.HIGH_QUALITY, - isMeteringEnabled: enableVAD, - }; - console.log('βš™οΈ RECORDER: Opzioni registrazione:', recordingOptions); - - // Prepara e avvia la registrazione - console.log('🎬 RECORDER: Preparazione registrazione...'); await this.recording.prepareToRecordAsync(recordingOptions); - console.log('βœ… RECORDER: Registrazione preparata'); - - console.log('▢️ RECORDER: Avvio registrazione...'); await this.recording.startAsync(); - console.log('βœ… RECORDER: Registrazione avviata!'); this.isRecording = true; this.recordingStartTime = Date.now(); this.vadEnabled = enableVAD; this.vadCallbacks = vadCallbacks || {}; - // Avvia il monitoraggio VAD se abilitato if (enableVAD) { - console.log('🎚️ RECORDER: Avvio monitoraggio VAD...'); this.startVADMonitoring(); } - console.log('🎀 Registrazione audio iniziata', enableVAD ? '(VAD attivo)' : '(VAD disattivo)'); + console.log('Registrazione PCM16 avviata', enableVAD ? '(VAD attivo)' : ''); return true; } catch (error) { - console.error('❌ RECORDER: Errore avvio registrazione:', error); - console.error('❌ RECORDER: Stack trace:', error); + console.error('Errore avvio registrazione:', error); this.cleanup(); return false; } } /** - * Ferma la registrazione e restituisce i dati audio + * Ferma la registrazione e restituisce i dati PCM16 raw in base64 */ async stopRecording(): Promise { if (!this.recording || !this.isRecording) { @@ -221,20 +270,17 @@ export class AudioRecorder { try { await this.recording.stopAndUnloadAsync(); const uri = this.recording.getURI(); - + if (!uri) { console.error('URI della registrazione non disponibile'); return null; } - // Converti il file audio in base64 - const base64Data = await this.convertAudioToBase64(uri); - + const base64Data = await this.convertToRawPcm16Base64(uri); this.isRecording = false; - console.log('🎀 Registrazione completata'); - + console.log('Registrazione completata'); return base64Data; - + } catch (error) { console.error('Errore stop registrazione:', error); return null; @@ -244,78 +290,45 @@ export class AudioRecorder { } /** - * Converti un file audio in formato base64 + * Legge il file audio e restituisce PCM16 raw (senza header WAV) in base64 */ - private async convertAudioToBase64(audioUri: string): Promise { + private async convertToRawPcm16Base64(audioUri: string): Promise { try { - console.log('🎀 Inizio conversione base64 per URI:', audioUri); - - // Verifica se FileSystem Γ¨ disponibile - if (!FileSystem || !FileSystem.readAsStringAsync) { - console.error('FileSystem non disponibile o metodo readAsStringAsync mancante'); - return null; - } - - // Verifica se l'URI esiste const fileInfo = await FileSystem.getInfoAsync(audioUri); if (!fileInfo.exists) { console.error('File audio non esiste:', audioUri); return null; } - - console.log('🎀 File info:', fileInfo); - - // Leggiamo i primi bytes per verificare l'header del file - const headerBytes = await this.readFileHeader(audioUri); - console.log('🎀 Header bytes (primi 8):', headerBytes); - - // In React Native/Expo, usiamo FileSystem per leggere il file + const base64Data = await FileSystem.readAsStringAsync(audioUri, { encoding: FileSystem.EncodingType.Base64, }); - - console.log('🎀 Conversione base64 completata, lunghezza:', base64Data?.length || 0); - - // Debug: verifica i primi caratteri del base64 per identificare il formato - if (base64Data && base64Data.length > 20) { - const first20chars = base64Data.substring(0, 20); - console.log('🎀 Prime 20 chars base64:', first20chars); - - // Decodifica i primi bytes per vedere l'header - const headerBase64 = base64Data.substring(0, 32); // primi ~24 bytes - const headerBuffer = this.base64ToBytes(headerBase64); - console.log('🎀 Header decodificato:', Array.from(headerBuffer).map(b => b.toString(16).padStart(2, '0')).join(' ')); - } - - return base64Data; - + + if (!base64Data) return null; + + // Decodifica, rimuovi header WAV se presente, riencodifica + const fullBytes = decodeBase64(base64Data); + const pcm16Bytes = stripWavHeader(fullBytes); + const pcm16Base64 = encodeBase64(pcm16Bytes); + + console.log(`Audio convertito: ${fullBytes.length} bytes -> ${pcm16Bytes.length} bytes PCM16 raw`); + return pcm16Base64; + } catch (error) { - console.error('Errore conversione base64:', error); - console.error('URI problematico:', audioUri); - console.error('FileSystem disponibile:', !!FileSystem); - console.error('readAsStringAsync disponibile:', !!(FileSystem?.readAsStringAsync)); + console.error('Errore conversione PCM16:', error); return null; } } - /** - * Ottieni la durata della registrazione corrente - */ getRecordingDuration(): number { if (!this.isRecording) return 0; return Date.now() - this.recordingStartTime; } - /** - * Controlla se la registrazione Γ¨ attiva - */ isCurrentlyRecording(): boolean { return this.isRecording; } - /** - * Cancella la registrazione corrente - */ async cancelRecording(): Promise { if (this.recording && this.isRecording) { try { @@ -327,41 +340,6 @@ export class AudioRecorder { this.cleanup(); } - /** - * Legge i primi bytes del file per verificare l'header - */ - private async readFileHeader(audioUri: string): Promise { - try { - // Leggiamo tutto il file in base64, poi prendiamo solo i primi bytes - const fullBase64 = await FileSystem.readAsStringAsync(audioUri, { - encoding: FileSystem.EncodingType.Base64, - }); - - // Prendiamo solo i primi ~16 caratteri base64 (corrispondenti a ~12 bytes) - const headerBase64 = fullBase64.substring(0, 16); - const headerBytes = this.base64ToBytes(headerBase64); - return Array.from(headerBytes).slice(0, 8); - } catch (error) { - console.error('Errore lettura header:', error); - return []; - } - } - - /** - * Converte base64 in array di bytes - */ - private base64ToBytes(base64: string): Uint8Array { - try { - return decodeBase64(base64); - } catch (error) { - console.error('Errore conversione base64 to bytes:', error); - return new Uint8Array(0); - } - } - - /** - * Avvia il monitoraggio VAD - */ private startVADMonitoring(): void { this.meteringInterval = setInterval(async () => { if (!this.recording || !this.isRecording) { @@ -374,46 +352,26 @@ export class AudioRecorder { if (status.isRecording && status.metering !== undefined) { const meteringDB = status.metering; - - // Notifica aggiornamento livello audio this.vadCallbacks.onMeteringUpdate?.(meteringDB); - - // Processa il livello audio per VAD this.processMeteringLevel(meteringDB); } } catch (error) { console.error('Errore monitoraggio VAD:', error); } }, VAD_CONFIG.METERING_POLL_INTERVAL_MS); - - console.log('🎀 Monitoraggio VAD avviato'); } - /** - * Ferma il monitoraggio VAD - */ private stopVADMonitoring(): void { if (this.meteringInterval) { clearInterval(this.meteringInterval); this.meteringInterval = null; - console.log('🎀 Monitoraggio VAD fermato'); } } - /** - * Processa il livello audio per rilevare voce e silenzio - */ private processMeteringLevel(meteringDB: number): void { const recordingDuration = this.getRecordingDuration(); - // Log del livello audio ogni 500ms per debugging - if (recordingDuration % 500 < VAD_CONFIG.METERING_POLL_INTERVAL_MS) { - console.log(`πŸ“Š Audio Level: ${meteringDB.toFixed(1)} dB | Duration: ${(recordingDuration / 1000).toFixed(1)}s`); - } - - // Non attivare VAD se la registrazione Γ¨ troppo corta if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) { - console.log(`⏱️ VAD: Attesa iniziale... (${recordingDuration}ms/${VAD_CONFIG.MIN_RECORDING_DURATION_MS}ms)`); return; } @@ -422,47 +380,26 @@ export class AudioRecorder { if (!this.isSpeechDetected) { this.isSpeechDetected = true; this.vadCallbacks.onSpeechStart?.(); - console.log(`🎀 VAD: βœ… VOCE RILEVATA! (${meteringDB.toFixed(1)} dB > ${VAD_CONFIG.SPEECH_THRESHOLD_DB} dB)`); } - - // Reset timer silenzio quando si parla if (this.silenceStartTime) { - console.log(`πŸ”Š VAD: Voce continua, reset timer silenzio`); this.silenceStartTime = null; } } // Rilevamento silenzio else if (meteringDB < VAD_CONFIG.SILENCE_THRESHOLD_DB) { - // Inizia timer silenzio if (!this.silenceStartTime) { this.silenceStartTime = Date.now(); this.vadCallbacks.onSilenceDetected?.(); - console.log(`πŸ”‡ VAD: ⏸️ SILENZIO RILEVATO (${meteringDB.toFixed(1)} dB < ${VAD_CONFIG.SILENCE_THRESHOLD_DB} dB)`); } else { const silenceDuration = Date.now() - this.silenceStartTime; - - // Log progressivo del silenzio - if (silenceDuration % 300 < VAD_CONFIG.METERING_POLL_INTERVAL_MS) { - console.log(`⏱️ VAD: Silenzio da ${(silenceDuration / 1000).toFixed(1)}s (stop a ${(VAD_CONFIG.SILENCE_DURATION_MS / 1000).toFixed(1)}s)`); - } - - // Se il silenzio dura abbastanza, ferma la registrazione if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS && this.isSpeechDetected) { - console.log(`πŸ›‘ VAD: ⏹️ AUTO-STOP ATTIVATO! (silenzio prolungato ${(silenceDuration / 1000).toFixed(1)}s)`); this.vadCallbacks.onAutoStop?.(); this.vadCallbacks.onSpeechEnd?.(); } } } - // Zona intermedia (tra soglia silenzio e soglia voce) - else { - console.log(`πŸ“ VAD: Zona intermedia (${meteringDB.toFixed(1)} dB tra ${VAD_CONFIG.SILENCE_THRESHOLD_DB} e ${VAD_CONFIG.SPEECH_THRESHOLD_DB})`); - } } - /** - * Pulisce le risorse della registrazione - */ private cleanup(): void { this.stopVADMonitoring(); this.recording = null; @@ -475,220 +412,27 @@ export class AudioRecorder { } /** - * Classe per gestire la riproduzione audio con streaming + * Classe per gestire la riproduzione di chunk audio PCM16 */ export class AudioPlayer { private currentSound: Audio.Sound | null = null; - private chunkBuffer: Array<{ index?: number; data: string }> = []; + private chunkBuffer: { index?: number; data: string }[] = []; private seenChunkIndexes: Set = new Set(); private highestIndexedChunk: number = -1; private isPlaying: boolean = false; - private onCompleteCallback: (() => void) | null = null; - - // Buffer state tracking per diagnostica - private lastChunkReceivedTime: number = 0; - private chunkArrivalTimes: number[] = []; - private bufferStartTime: number = 0; - private isBufferingStarted: boolean = false; - - constructor() {} - - /** - * Converte base64 in array di bytes - */ - private base64ToBytes(base64: string): Uint8Array { - try { - return decodeBase64(base64); - } catch (error) { - console.error('Errore conversione base64 to bytes:', error); - return new Uint8Array(0); - } - } - - /** - * Converte bytes in base64 - */ - private bytesToBase64(bytes: Uint8Array): string { - try { - return encodeBase64(bytes); - } catch (error) { - console.error('Errore conversione bytes to base64:', error); - return ''; - } - } - - /** - * Tenta di individuare il formato audio dai primi bytes - */ - private detectAudioFormat(data: Uint8Array): 'wav' | 'mp3' | 'm4a' | 'ogg' | 'unknown' { - if (data.length < 12) return 'unknown'; - - // RIFF/WAVE - if ( - data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && - data[8] === 0x57 && data[9] === 0x41 && data[10] === 0x56 && data[11] === 0x45 - ) { - return 'wav'; - } - - // MP3 (ID3 tag o frame sync 0xfff*) - if ( - (data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33) || // ID3 - (data[0] === 0xff && (data[1] & 0xe0) === 0xe0) // frame sync - ) { - return 'mp3'; - } - - // OGG - if (data[0] === 0x4f && data[1] === 0x67 && data[2] === 0x67 && data[3] === 0x53) { - return 'ogg'; - } - - // MP4/M4A (ftyp atom) - if ( - data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70 && - data[8] === 0x4d && data[9] === 0x34 && data[10] === 0x41 - ) { - return 'm4a'; - } - - return 'unknown'; - } - - /** - * Controlla se il buffer Γ¨ pronto per la riproduzione - * Ritorna true se abbiamo almeno MIN_CHUNKS_BEFORE_PLAYBACK chunk - */ - isReadyToPlay(): boolean { - const bufferedCount = this.getBufferedChunksCount(); - const isReady = bufferedCount >= VOICE_CHUNK_CONFIG.MIN_CHUNKS_BEFORE_PLAYBACK; - - if (!isReady && bufferedCount > 0) { - console.log(`πŸ”Š Buffer non pronto: ${bufferedCount}/${VOICE_CHUNK_CONFIG.MIN_CHUNKS_BEFORE_PLAYBACK} chunk`); - } - - return isReady; - } - - /** - * Ottiene il numero di chunk attualmente nel buffer - */ - getBufferedChunksCount(): number { - return this.chunkBuffer.length; - } - - /** - * Ottiene statistiche sull'arrivo dei chunk - */ - getChunkArrivalStatistics(): { - totalReceived: number; - averageInterArrivalMs: number; - minInterArrivalMs: number; - maxInterArrivalMs: number; - bursts: number; - } | null { - if (this.chunkArrivalTimes.length < 2) return null; - - const interArrivals: number[] = []; - for (let i = 1; i < this.chunkArrivalTimes.length; i++) { - interArrivals.push(this.chunkArrivalTimes[i] - this.chunkArrivalTimes[i - 1]); - } - - const burstCount = interArrivals.filter( - time => time < VOICE_CHUNK_CONFIG.BURST_DETECTION_THRESHOLD_MS - ).length; - - const avgInterArrival = interArrivals.reduce((a, b) => a + b, 0) / interArrivals.length; - - return { - totalReceived: this.chunkArrivalTimes.length, - averageInterArrivalMs: avgInterArrival, - minInterArrivalMs: Math.min(...interArrivals), - maxInterArrivalMs: Math.max(...interArrivals), - bursts: burstCount, - }; - } /** - * Riproduce audio da dati base64 concatenati - */ - async playAudioFromBase64(base64Data: string, onComplete?: () => void): Promise { - try { - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - playsInSilentModeIOS: true, - staysActiveInBackground: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, - }); - - const tempUri = `${FileSystem.documentDirectory}temp_audio_${Date.now()}.m4a`; - - await FileSystem.writeAsStringAsync(tempUri, base64Data, { - encoding: FileSystem.EncodingType.Base64, - }); - - const { sound } = await Audio.Sound.createAsync({ uri: tempUri }); - this.currentSound = sound; - - this.currentSound.setOnPlaybackStatusUpdate((status) => { - if (status.isLoaded && status.didJustFinish) { - console.log('πŸ”Š Riproduzione completata'); - this.onPlaybackComplete(onComplete); - } - }); - - await this.currentSound.playAsync(); - this.isPlaying = true; - - console.log('πŸ”Š Riproduzione audio iniziata'); - return true; - - } catch (error) { - console.error('πŸ”Š Errore riproduzione audio:', error); - return false; - } - } - - /** - * Aggiunge un chunk alla collezione + * Aggiunge un chunk PCM16 base64 al buffer */ addChunk(base64Data: string, chunkIndex?: number): boolean { - const currentTime = Date.now(); - - // Traccia timing arrivo chunk (per prima volta) - if (!this.isBufferingStarted) { - this.isBufferingStarted = true; - this.bufferStartTime = currentTime; - console.log(`πŸ”Š ⏱️ INIZIO BUFFERING chunk audio`); - } - - // Traccia inter-arrival time - if (this.lastChunkReceivedTime > 0) { - const interArrivalMs = currentTime - this.lastChunkReceivedTime; - this.chunkArrivalTimes.push(currentTime); - - if (interArrivalMs < VOICE_CHUNK_CONFIG.BURST_DETECTION_THRESHOLD_MS) { - console.warn(`πŸ”Š ⚑ BURST RILEVATO: ${interArrivalMs}ms tra chunk`); - } - } else { - this.chunkArrivalTimes.push(currentTime); - } - - this.lastChunkReceivedTime = currentTime; - if (typeof chunkIndex === 'number') { if (this.seenChunkIndexes.has(chunkIndex)) { - console.warn(`πŸ”Š Chunk duplicato ricevuto (indice ${chunkIndex}) - ignorato`); + console.warn(`Chunk duplicato (indice ${chunkIndex}) - ignorato`); return false; } - if (this.highestIndexedChunk >= 0 && chunkIndex > this.highestIndexedChunk + 1) { - console.warn(`πŸ”Š Mancano uno o piΓΉ chunk prima dell'indice ${chunkIndex} (ultimo ricevuto ${this.highestIndexedChunk})`); - } - if (this.highestIndexedChunk >= 0 && chunkIndex < this.highestIndexedChunk) { - console.warn(`πŸ”Š Chunk fuori ordine rilevato: indice ${chunkIndex} ricevuto dopo ${this.highestIndexedChunk}`); + console.warn(`Chunk fuori ordine: indice ${chunkIndex} dopo ${this.highestIndexedChunk}`); } this.seenChunkIndexes.add(chunkIndex); @@ -696,93 +440,65 @@ export class AudioPlayer { } this.chunkBuffer.push({ index: chunkIndex, data: base64Data }); - const bufferedCount = this.getChunksCount(); - console.log(`πŸ”Š Chunk #${typeof chunkIndex === 'number' ? chunkIndex : '?'} aggiunto. Buffer: ${bufferedCount}/${VOICE_CHUNK_CONFIG.MIN_CHUNKS_BEFORE_PLAYBACK}`); - return true; } /** - * Unisce TUTTI i chunk in un singolo file, poi lo riproduce - * Salva il file concatenato su disco prima di riprodurre + * Concatena tutti i chunk PCM16, li wrappa in WAV e li riproduce */ - - async playChunksSequentially(onComplete?: () => void): Promise { + async playPcm16Chunks(onComplete?: () => void): Promise { const totalChunks = this.getChunksCount(); - if (totalChunks === 0) { console.log('AudioPlayer: Nessun chunk da riprodurre'); return false; } - console.log(`AudioPlayer: Unione di ${totalChunks} chunk in corso...`); - try { + // Ordina i chunk per indice const indexedChunks = this.chunkBuffer .filter(chunk => typeof chunk.index === 'number') .sort((a, b) => (a.index as number) - (b.index as number)); const nonIndexedChunks = this.chunkBuffer.filter(chunk => typeof chunk.index !== 'number'); + const allChunks = [...indexedChunks, ...nonIndexedChunks]; - const playbackQueue = [...indexedChunks, ...nonIndexedChunks]; - - if (playbackQueue.length === 0) { - console.warn('AudioPlayer: Nessun chunk valido da riprodurre'); + if (allChunks.length === 0) { this.clearChunks(); return false; } - console.log('AudioPlayer: Step 1, decodifica chunk base64 e concatenazione binari...'); - - // Decodifica OGNI chunk base64 completamente a binario + // Decodifica tutti i chunk in binario const binaryChunks: Uint8Array[] = []; - for (const chunk of playbackQueue) { - const binaryData = this.base64ToBytes(chunk.data); - if (binaryData.length > 0) { - binaryChunks.push(binaryData); - console.log(` Chunk decodificato: ${binaryData.length} bytes`); + for (const chunk of allChunks) { + const binary = decodeBase64(chunk.data); + if (binary.length > 0) { + binaryChunks.push(binary); } } if (binaryChunks.length === 0) { - console.warn('AudioPlayer: Nessun chunk valido da decodificare'); this.clearChunks(); return false; } - // Concatena i binari usando Uint8Array.set() - const totalBinaryLength = binaryChunks.reduce((acc, chunk) => acc + chunk.length, 0); - const totalBinaryData = new Uint8Array(totalBinaryLength); + // Concatena tutti i dati PCM16 + const totalLength = binaryChunks.reduce((acc, c) => acc + c.length, 0); + const pcm16Data = new Uint8Array(totalLength); let offset = 0; - - binaryChunks.forEach((chunk) => { - totalBinaryData.set(chunk, offset); + for (const chunk of binaryChunks) { + pcm16Data.set(chunk, offset); offset += chunk.length; - }); - - console.log(`AudioPlayer: Step 1 completato (${totalBinaryData.length} bytes binari da ${binaryChunks.length} chunk)`); - - const detectedFormat = this.detectAudioFormat(totalBinaryData); - const extension = detectedFormat === 'unknown' ? 'm4a' : detectedFormat; - if (detectedFormat === 'unknown') { - console.warn('AudioPlayer: Formato audio non rilevato, uso fallback .m4a'); - } else { - console.log(`AudioPlayer: Formato audio rilevato -> ${detectedFormat}`); } - console.log(`AudioPlayer: Dati audio decodificati (${totalBinaryData.length} bytes)`); + console.log(`AudioPlayer: ${totalChunks} chunk -> ${pcm16Data.length} bytes PCM16`); - console.log('AudioPlayer: Step 2, salvataggio file audio concatenato...'); - const finalAudioPath = `${FileSystem.documentDirectory}final_audio_${Date.now()}.${extension}`; + // Wrappa in WAV per la riproduzione con expo-av + const wavData = wrapPcm16InWav(pcm16Data, AUDIO_CONFIG.SAMPLE_RATE); + const wavBase64 = encodeBase64(wavData); - // Riencodifica a base64 per scrivere il file (richiesto da FileSystem) - const finalBase64 = this.bytesToBase64(totalBinaryData); - - await FileSystem.writeAsStringAsync(finalAudioPath, finalBase64, { + const tempPath = `${FileSystem.documentDirectory}voice_response_${Date.now()}.wav`; + await FileSystem.writeAsStringAsync(tempPath, wavBase64, { encoding: FileSystem.EncodingType.Base64, }); - console.log(`AudioPlayer: Step 2 completato (file: ${finalAudioPath.split('/').pop()})`); - - console.log('AudioPlayer: Step 3, avvio riproduzione file concatenato...'); await Audio.setAudioModeAsync({ allowsRecordingIOS: false, @@ -792,199 +508,25 @@ export class AudioPlayer { playThroughEarpieceAndroid: false, }); - const { sound } = await Audio.Sound.createAsync({ uri: finalAudioPath }); + const { sound } = await Audio.Sound.createAsync({ uri: tempPath }); this.currentSound = sound; this.currentSound.setOnPlaybackStatusUpdate(async (status) => { if (status.isLoaded && status.didJustFinish) { console.log('AudioPlayer: Riproduzione completata'); - try { - await this.currentSound?.unloadAsync(); - } catch (e) { - console.warn('AudioPlayer: Errore unload audio'); - } - - try { - await FileSystem.deleteAsync(finalAudioPath); - console.log('AudioPlayer: File temporaneo eliminato'); - } catch (e) { - console.warn('AudioPlayer: Errore eliminazione file temporaneo'); - } - - this.clearChunks(); - onComplete?.(); + await this.onPlaybackComplete(onComplete, tempPath); } }); await this.currentSound.playAsync(); this.isPlaying = true; - console.log('AudioPlayer: Step 3 completato, riproduzione avviata'); - - return true; - } catch (error) { - console.error('AudioPlayer: Errore durante la riproduzione concatenata:', error); - this.clearChunks(); - return false; - } - } - - /** - * Concatena tutti i chunk e li riproduce - - * Salva i chunk in un singolo file e lo riproduce - */ - - async playAllChunks(onComplete?: () => void): Promise { - const totalChunks = this.getChunksCount(); - - if (totalChunks === 0) { - console.log('AudioPlayer: Nessun chunk da riprodurre'); - return false; - } - - const stats = this.getChunkArrivalStatistics(); - console.log('AudioPlayer: Stato buffer per playback'); - console.log(` - Total chunks: ${totalChunks}`); - if (stats) { - console.log(` - Avg inter-arrival: ${stats.averageInterArrivalMs.toFixed(2)}ms`); - console.log(` - Min/Max: ${stats.minInterArrivalMs}ms / ${stats.maxInterArrivalMs}ms`); - console.log(` - Burst events: ${stats.bursts}`); - } - if (this.isBufferingStarted) { - const bufferDuration = this.lastChunkReceivedTime - this.bufferStartTime; - console.log(` - Buffer duration: ${bufferDuration}ms`); - } - - console.log(`AudioPlayer: Inizio concatenazione di ${totalChunks} chunks...`); - - try { - const indexedChunks = this.chunkBuffer - .filter(chunk => typeof chunk.index === 'number') - .sort((a, b) => (a.index as number) - (b.index as number)); - const nonIndexedChunks = this.chunkBuffer.filter(chunk => typeof chunk.index !== 'number'); - - if (indexedChunks.length > 1) { - const sortedIndexes = indexedChunks.map(chunk => chunk.index as number); - for (let i = 1; i < sortedIndexes.length; i++) { - const expected = sortedIndexes[i - 1] + 1; - if (sortedIndexes[i] !== expected) { - if (sortedIndexes[i] < expected) { - console.warn(`AudioPlayer: Ordine chunk non crescente: indice ${sortedIndexes[i]} dopo ${sortedIndexes[i - 1]}`); - } else { - console.warn(`AudioPlayer: Mancano ${sortedIndexes[i] - expected} chunk audio prima dell'indice ${sortedIndexes[i]}`); - } - } - } - } - - const playbackQueue = [...indexedChunks, ...nonIndexedChunks]; - - if (playbackQueue.length === 0) { - console.warn('AudioPlayer: Nessun chunk valido da riprodurre dopo il filtraggio'); - this.clearChunks(); - return false; - } - - console.log('AudioPlayer: Decodifica chunk base64 e concatenazione binari...'); - - // Decodifica OGNI chunk base64 completamente a binario - const binaryChunks: Uint8Array[] = []; - let processedChunkCount = 0; - - for (const chunk of playbackQueue) { - try { - const binaryData = this.base64ToBytes(chunk.data); - - if (binaryData.length === 0) { - console.warn(`AudioPlayer: Chunk ${chunk.index ?? processedChunkCount} vuoto, ignorato`); - continue; - } - - binaryChunks.push(binaryData); - processedChunkCount++; - console.log(`AudioPlayer: Chunk ${chunk.index ?? processedChunkCount} decodificato (${binaryData.length} bytes)`); - } catch (chunkError) { - console.warn(`AudioPlayer: Errore decodifica chunk ${chunk.index ?? processedChunkCount}:`, chunkError); - } - } - - if (binaryChunks.length === 0) { - console.warn('AudioPlayer: Nessun chunk audio valido dopo la decodifica'); - this.clearChunks(); - return false; - } - - // Concatena i binari usando Uint8Array.set() - const totalBinaryLength = binaryChunks.reduce((acc, chunk) => acc + chunk.length, 0); - const totalBinaryData = new Uint8Array(totalBinaryLength); - let offset = 0; - - binaryChunks.forEach((chunk) => { - totalBinaryData.set(chunk, offset); - offset += chunk.length; - }); - - const detectedFormat = this.detectAudioFormat(totalBinaryData); - const extension = detectedFormat === 'unknown' ? 'm4a' : detectedFormat; - if (detectedFormat === 'unknown') { - console.warn('AudioPlayer: Formato audio non rilevato, uso fallback .m4a'); - } else { - console.log(`AudioPlayer: Formato audio rilevato -> ${detectedFormat}`); - } - - const finalAudioPath = `${FileSystem.documentDirectory}final_audio_${Date.now()}.${extension}`; - const completeAudioBase64 = this.bytesToBase64(totalBinaryData); - - console.log('AudioPlayer: Audio concatenato pronto:'); - console.log(` - Chunks elaborati: ${binaryChunks.length}`); - console.log(` - Dimensione binaria: ${totalBinaryData.length} bytes`); - console.log(` - Dimensione base64: ${completeAudioBase64.length} caratteri`); - console.log(` - Salvataggio file: ${finalAudioPath.split('/').pop()}`); - - await FileSystem.writeAsStringAsync(finalAudioPath, completeAudioBase64, { - encoding: FileSystem.EncodingType.Base64, - }); - this.clearChunks(); - console.log('AudioPlayer: Riproduzione file audio...'); - - try { - await Audio.setAudioModeAsync({ - allowsRecordingIOS: false, - playsInSilentModeIOS: true, - staysActiveInBackground: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, - }); - - const { sound } = await Audio.Sound.createAsync({ uri: finalAudioPath }); - this.currentSound = sound; - - this.currentSound.setOnPlaybackStatusUpdate((status) => { - if (status.isLoaded && status.didJustFinish) { - console.log('AudioPlayer: Riproduzione completata'); - this.onPlaybackComplete(onComplete, finalAudioPath); - } - }); - - await this.currentSound.playAsync(); - this.isPlaying = true; - console.log('AudioPlayer: Riproduzione audio iniziata'); - return true; - } catch (error) { - console.error('AudioPlayer: Errore riproduzione:', error); - - try { - await FileSystem.deleteAsync(finalAudioPath); - } catch { - console.warn('AudioPlayer: Errore eliminazione file temporaneo'); - } - return false; - } + console.log('AudioPlayer: Riproduzione WAV avviata'); + return true; } catch (error) { - console.error('AudioPlayer: Errore concatenazione:', error); + console.error('AudioPlayer: Errore riproduzione PCM16:', error); this.clearChunks(); return false; } @@ -992,88 +534,56 @@ export class AudioPlayer { /** * Svuota i chunk accumulati - */ clearChunks(): void { this.chunkBuffer = []; this.seenChunkIndexes.clear(); this.highestIndexedChunk = -1; - - // Reset timing per prossimo ciclo - this.lastChunkReceivedTime = 0; - this.chunkArrivalTimes = []; - this.bufferStartTime = 0; - this.isBufferingStarted = false; - - console.log('πŸ”Š Chunks svuotati e timing reset'); } - /** - * Gestisce il completamento della riproduzione - */ private async onPlaybackComplete(onComplete?: () => void, audioFilePath?: string): Promise { if (this.currentSound) { try { await this.currentSound.unloadAsync(); } catch (error) { - console.error('πŸ”Š Errore cleanup audio:', error); + console.error('Errore cleanup audio:', error); } this.currentSound = null; } - // Pulisci il file audio temporaneo if (audioFilePath) { try { await FileSystem.deleteAsync(audioFilePath); - console.log('πŸ”Š File audio temporaneo eliminato'); } catch { - console.warn('πŸ”Š Errore eliminazione file audio temporaneo'); + // Ignora errore eliminazione file temp } } this.isPlaying = false; - - if (onComplete) { - onComplete(); - } + onComplete?.(); } - /** - * Ferma la riproduzione corrente - */ async stopPlayback(): Promise { if (this.currentSound) { try { await this.currentSound.stopAsync(); await this.currentSound.unloadAsync(); - console.log('πŸ”Š Riproduzione fermata'); } catch (error) { console.error('Errore stop riproduzione:', error); } this.currentSound = null; } - this.isPlaying = false; - this.onCompleteCallback = null; } - /** - * Controlla se la riproduzione Γ¨ attiva - */ isCurrentlyPlaying(): boolean { return this.isPlaying; } - /** - * Ottiene il numero di chunk accumulati - */ getChunksCount(): number { return this.chunkBuffer.length; } - /** - * Distrugge il player e pulisce tutte le risorse - */ async destroy(): Promise { await this.stopPlayback(); this.clearChunks(); @@ -1116,6 +626,6 @@ export function formatDuration(milliseconds: number): string { const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; - + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } diff --git a/voice_bot_websocket_api.md b/voice_bot_websocket_api.md new file mode 100644 index 0000000..5a7b980 --- /dev/null +++ b/voice_bot_websocket_api.md @@ -0,0 +1,375 @@ +# Voice Bot WebSocket API Documentation + +## Endpoint + +``` +WS /chat/voice-bot-websocket +``` + +WebSocket endpoint for real-time voice interaction with the AI assistant. Uses the OpenAI Realtime API with MCP tools, handling transcription, AI reasoning, tool execution, and TTS natively β€” no separate Whisper or TTS calls needed. + +--- + +## Connection Flow + +``` +Client Server + | | + |--- WebSocket connect --------->| + |<-- connection accepted --------| + | | + |--- { type: "auth", token } --->| Phase 1: Authentication + |<-- { type: "status", | + | phase: "authenticated" } -| + | | + | (server sets up MCP + | Phase 2: Setup + | RealtimeAgent internally) | + |<-- { type: "status", | + | phase: "ready" } ---------| + | | + |<== bidirectional messages ====>| Phase 3: Conversation + | | + |<-- { type: "done" } -----------| Session ended +``` + +--- + +## Phase 1: Authentication + +The **first message** sent by the client **must** be an authentication message. Any other message type before authentication will return an error. + +### Request + +```json +{ + "type": "auth", + "token": "" +} +``` + +| Field | Type | Required | Description | +|---------|--------|----------|--------------------------------------------------------------------| +| `type` | string | yes | Must be `"auth"` | +| `token` | string | yes | JWT access token. May optionally include the `"Bearer "` prefix. | + +### Responses + +**Success:** + +```json +{ + "type": "status", + "phase": "authenticated", + "message": "Autenticato come (ID: )" +} +``` + +**Error β€” missing token:** + +```json +{ + "type": "error", + "message": "Token di autenticazione richiesto" +} +``` + +**Error β€” invalid token:** + +```json +{ + "type": "error", + "message": "Token non valido: " +} +``` + +**Error β€” user not found:** + +```json +{ + "type": "error", + "message": "Utente non trovato" +} +``` + +> After a failed auth attempt the connection remains open and the client can retry. + +--- + +## Phase 2: Setup (automatic) + +After successful authentication the server: + +1. Reads the user's `voice_gender` preference (`"female"` or `"male"`) and selects the corresponding voice (`coral` / `echo`). +2. Connects to the MCP server with a scoped JWT. +3. Creates a `RealtimeAgent` with MCP tools and the system prompt. +4. Opens the OpenAI Realtime session configured with: + - Audio format: PCM16 + - Transcription model: `gpt-4o-mini-transcribe` (language: `it`) + - Turn detection: semantic VAD with interrupt support + +When ready the server sends: + +```json +{ + "type": "status", + "phase": "ready", + "message": "Sessione vocale pronta" +} +``` + +The client should **wait for this message** before sending audio or text. + +--- + +## Phase 3: Conversation + +### Client -> Server messages + +#### Send text + +```json +{ + "type": "text", + "content": "Quali task ho per oggi?" +} +``` + +| Field | Type | Required | Description | +|-----------|--------|----------|------------------------| +| `type` | string | yes | `"text"` | +| `content` | string | yes | Plain text message | + +#### Send audio + +Stream audio data in chunks. Audio must be **PCM16** encoded. + +```json +{ + "type": "audio", + "data": "" +} +``` + +| Field | Type | Required | Description | +|--------|--------|----------|------------------------------------| +| `type` | string | yes | `"audio"` | +| `data` | string | yes | Base64-encoded PCM16 audio bytes | + +#### Commit audio buffer + +Signal the end of an audio utterance to trigger processing: + +```json +{ + "type": "audio_commit" +} +``` + +> Only needed if the client wants to explicitly commit; otherwise semantic VAD handles turn detection automatically. + +#### Interrupt + +Cancel the current assistant response (e.g. when the user starts speaking): + +```json +{ + "type": "interrupt" +} +``` + +--- + +### Server -> Client messages + +#### Status updates + +```json +{ + "type": "status", + "phase": "", + "message": "" +} +``` + +| `phase` | Meaning | +|------------------|--------------------------------------| +| `authenticated` | Auth succeeded | +| `ready` | Session ready for input | +| `interrupted` | Audio playback interrupted | +| `audio_end` | Assistant finished speaking | +| `agent_start` | Agent started processing | +| `agent_end` | Agent finished processing | + +#### Audio response + +```json +{ + "type": "audio", + "data": "", + "chunk_index": 0 +} +``` + +| Field | Type | Description | +|---------------|--------|----------------------------------------------| +| `data` | string | Base64-encoded PCM16 audio chunk | +| `chunk_index` | int | Sequential index (resets to 0 each turn) | + +The client should decode and play these chunks sequentially. + +#### Transcript + +```json +{ + "type": "transcript", + "role": "user" | "assistant", + "content": "Hai 3 task per oggi..." +} +``` + +| Field | Type | Description | +|-----------|--------|----------------------------------------------| +| `role` | string | `"user"` (transcribed speech) or `"assistant"` (generated text) | +| `content` | string | Transcript text | + +#### Tool execution + +**Start:** + +```json +{ + "type": "tool_start", + "tool_name": "get_tasks", + "arguments": "{\"date\": \"2026-01-29\"}" +} +``` + +**End:** + +```json +{ + "type": "tool_end", + "tool_name": "get_tasks", + "output": "[{\"title\": \"Meeting\", ...}]" +} +``` + +#### Error + +```json +{ + "type": "error", + "message": "" +} +``` + +#### Done + +Sent when the session ends cleanly: + +```json +{ + "type": "done" +} +``` + +--- + +## Audio Format + +| Parameter | Value | +|--------------|-----------------| +| Encoding | PCM16 (signed 16-bit little-endian) | +| Sample rate | 24000 Hz | +| Channels | 1 (mono) | +| Transport | Base64 over JSON | + +--- + +## Example Client (JavaScript) + +```javascript +const ws = new WebSocket("wss://api.mytasklyapp.com/chat/voice-bot-websocket"); + +ws.onopen = () => { + // Step 1: Authenticate + ws.send(JSON.stringify({ + type: "auth", + token: accessToken + })); +}; + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + switch (msg.type) { + case "status": + if (msg.phase === "ready") { + // Session ready β€” can now send audio/text + startRecording(); + } + break; + + case "audio": + // Decode and enqueue for playback + const pcm = base64ToArrayBuffer(msg.data); + audioPlayer.enqueue(pcm); + break; + + case "transcript": + console.log(`[${msg.role}]: ${msg.content}`); + break; + + case "tool_start": + console.log(`Calling tool: ${msg.tool_name}`); + break; + + case "tool_end": + console.log(`Tool result: ${msg.output}`); + break; + + case "error": + console.error("Server error:", msg.message); + break; + + case "done": + ws.close(); + break; + } +}; + +// Send audio chunk from microphone +function onAudioChunk(pcm16Buffer) { + const b64 = arrayBufferToBase64(pcm16Buffer); + ws.send(JSON.stringify({ type: "audio", data: b64 })); +} + +// Interrupt assistant +function onUserStartsSpeaking() { + ws.send(JSON.stringify({ type: "interrupt" })); +} +``` + +--- + +## Error Handling + +| Scenario | Behavior | +|-----------------------------|-------------------------------------------------------| +| No auth message first | Server responds with error, connection stays open | +| Invalid/expired token | Server responds with error, client can retry auth | +| User not found in DB | Server responds with error, client can retry auth | +| MCP server unreachable | Server sends error and closes the connection | +| OpenAI session failure | Server sends error and closes the connection | +| Client disconnects | Server cleans up MCP and Realtime session gracefully | +| Unknown message type | Server responds with error, connection stays open | + +--- + +## Notes + +- The voice used by the assistant depends on the user's `voice_gender` setting in the database (`"female"` -> `coral`, `"male"` -> `echo`). +- Turn detection uses **semantic VAD** β€” the server automatically detects when the user stops speaking. Manual `audio_commit` is optional. +- The `interrupt` message cancels the current assistant response, useful for barge-in scenarios. +- MCP tools (task management, calendar, etc.) are available to the voice assistant and execute automatically when needed. +- This endpoint does **not** require the `X-API-Key` header (authentication is handled via the WebSocket auth message). From 7a26d2d15e19db2ea43754700c2c629362801ebd Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 30 Jan 2026 11:06:35 +0100 Subject: [PATCH 02/29] fix: adjust recording delay and improve error handling in audio recorder --- src/hooks/useVoiceChat.ts | 2 +- src/utils/audioUtils.ts | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index a12b5fc..24211e6 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -138,7 +138,7 @@ export function useVoiceChat() { shouldAutoStartRecordingRef.current = false; setTimeout(() => { startRecording(); - }, 100); + }, 500); } }, diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index 89e7b5a..aa71b96 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -175,8 +175,8 @@ function getPcm16RecordingOptions(enableMetering: boolean) { isMeteringEnabled: enableMetering, android: { extension: '.wav', - outputFormat: 6, // DEFAULT - audioEncoder: 0, // DEFAULT + outputFormat: 0, // AndroidOutputFormat.DEFAULT + audioEncoder: 0, // AndroidAudioEncoder.DEFAULT sampleRate: AUDIO_CONFIG.SAMPLE_RATE, numberOfChannels: AUDIO_CONFIG.CHANNELS, bitRate: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, @@ -233,11 +233,17 @@ export class AudioRecorder { playThroughEarpieceAndroid: false, }); - this.recording = new Audio.Recording(); const recordingOptions = getPcm16RecordingOptions(enableVAD); - - await this.recording.prepareToRecordAsync(recordingOptions); - await this.recording.startAsync(); + const { recording } = await Audio.Recording.createAsync(recordingOptions); + this.recording = recording; + + // Verifica che il recorder nativo sia effettivamente attivo + const status = await this.recording.getStatusAsync(); + if (!status.isRecording) { + console.error('Il recorder nativo non Γ¨ partito correttamente'); + this.cleanup(); + return false; + } this.isRecording = true; this.recordingStartTime = Date.now(); @@ -356,7 +362,9 @@ export class AudioRecorder { this.processMeteringLevel(meteringDB); } } catch (error) { - console.error('Errore monitoraggio VAD:', error); + // Il recorder nativo non Γ¨ piΓΉ disponibile, ferma il monitoraggio + console.warn('VAD: recorder non disponibile, monitoraggio fermato'); + this.stopVADMonitoring(); } }, VAD_CONFIG.METERING_POLL_INTERVAL_MS); } From 2a15438c471ca986263e9162c01dc3e18e591abe Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 30 Jan 2026 11:30:07 +0100 Subject: [PATCH 03/29] feat: enhance audio processing and update VAD configuration for improved detection --- src/hooks/useVoiceChat.ts | 16 +++++++++++++++- src/utils/audioUtils.ts | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 24211e6..e071080 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -343,14 +343,28 @@ export function useVoiceChat() { try { const pcm16Base64 = await audioRecorderRef.current.stopRecording(); if (!pcm16Base64) { + console.error('❌ Nessun dato audio registrato'); setError('Nessun dato audio registrato'); setState('error'); return false; } - // Invia audio PCM16 e committa + // Calcola la durata approssimativa dell'audio + const audioBytes = Math.floor(pcm16Base64.length * 0.75); + const durationMs = (audioBytes / 2) / 24; // 2 bytes per sample, 24 samples per ms + + console.log(`πŸ“¦ Audio pronto: ${durationMs.toFixed(0)}ms (${audioBytes} bytes)`); + + // Invia audio PCM16 websocketRef.current.sendAudio(pcm16Base64); + console.log('πŸ“€ Audio inviato al server'); + + // IMPORTANTE: Aspetta 300ms per assicurare che il server processi l'audio + // prima di committare il buffer. Questo previene l'errore "buffer_empty" + await new Promise(resolve => setTimeout(resolve, 300)); + websocketRef.current.sendAudioCommit(); + console.log('βœ… Buffer committato'); setState('processing'); setRecordingDuration(0); diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index aa71b96..ea03828 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -80,11 +80,11 @@ export const AUDIO_CONFIG = { // Configurazioni VAD (Voice Activity Detection) - solo per feedback UI // Il server gestisce il turn detection con semantic VAD export const VAD_CONFIG = { - SPEECH_THRESHOLD_DB: -50, - SILENCE_THRESHOLD_DB: -60, + SPEECH_THRESHOLD_DB: -60, + SILENCE_THRESHOLD_DB: -70, SILENCE_DURATION_MS: 1200, METERING_POLL_INTERVAL_MS: 100, - MIN_RECORDING_DURATION_MS: 300, + MIN_RECORDING_DURATION_MS: 500, }; // Configurazione per normalizzazione audio level From 3ad6964e836571a728fdab0e82401ca413df5743 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 2 Feb 2026 10:10:28 +0100 Subject: [PATCH 04/29] feat: integrate expo-audio and expo-audio-studio for enhanced voice chat functionality --- .claude/settings.local.json | 5 +- app.json | 9 +- package-lock.json | 27 +++ package.json | 2 + src/hooks/useVoiceChat.ts | 44 ++--- src/services/botservice.ts | 13 +- src/utils/audioUtils.ts | 347 ++++++++++++++++-------------------- 7 files changed, 223 insertions(+), 224 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c83e9b7..f16d4f4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -55,7 +55,10 @@ "Bash([ -s \"$NVM_DIR/nvm.sh\" ])", "Bash(. \"$NVM_DIR/nvm.sh\")", "Bash(npm test:*)", - "Bash(xargs:*)" + "Bash(xargs:*)", + "WebFetch(domain:www.npmjs.com)", + "WebFetch(domain:docs.expo.dev)", + "WebFetch(domain:github.com)" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/app.json b/app.json index 5063c7f..b003d50 100644 --- a/app.json +++ b/app.json @@ -74,7 +74,14 @@ } ], "expo-dev-client", - "expo-router" + "expo-router", + "expo-audio", + [ + "expo-audio-studio", + { + "microphonePermission": "Allow MyTaskly to access your microphone for voice chat" + } + ] ], "extra": { "eas": { diff --git a/package-lock.json b/package-lock.json index c9338df..34963b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "dotenv": "^17.2.1", "eventsource": "^4.0.0", "expo": "~53.0.23", + "expo-audio": "~0.4.9", + "expo-audio-studio": "^1.3.1", "expo-av": "^15.1.7", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", @@ -8734,6 +8736,31 @@ "react-native": "*" } }, + "node_modules/expo-audio": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-0.4.9.tgz", + "integrity": "sha512-J4mMYEt2mqRqqwmSsXFylMGlrNWa+MbCzGl1IZBs+smvPAMJ3Ni8fNplzCQ0I9RnRzygKhRwJNpnAVL+n4MuyA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-audio-studio": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/expo-audio-studio/-/expo-audio-studio-1.3.1.tgz", + "integrity": "sha512-eEN+xG30ZlfcTilOMl6cJKxGSJAbObCpkvPxyqI2r0unDJxC0RhHpSqAkr2af7n/zorsvnm1n+8TS+g7X7ap6Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-av": { "version": "15.1.7", "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-15.1.7.tgz", diff --git a/package.json b/package.json index 3329e34..bf0a3a3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "dotenv": "^17.2.1", "eventsource": "^4.0.0", "expo": "~53.0.23", + "expo-audio": "~0.4.9", + "expo-audio-studio": "^1.3.1", "expo-av": "^15.1.7", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index e071080..a7de9d0 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -45,6 +45,7 @@ export interface ActiveTool { /** * Hook personalizzato per la gestione della chat vocale * Compatibile con l'OpenAI Realtime API tramite WebSocket + * Usa expo-audio-studio per streaming chunks PCM16 base64 in tempo reale */ export function useVoiceChat() { // Stati principali @@ -93,7 +94,7 @@ export function useVoiceChat() { }, []); /** - * VAD Callbacks β€” solo per feedback UI, il turn detection e' gestito dal server + * VAD Callbacks β€” per feedback UI + auto-stop su silenzio prolungato */ const vadCallbacks: VADCallbacks = { onSpeechStart: () => { @@ -106,7 +107,7 @@ export function useVoiceChat() { setIsSpeechActive(false); }, onAutoStop: async () => { - // Client-side VAD rileva silenzio prolungato: ferma registrazione e invia + // VAD rileva silenzio prolungato: ferma registrazione e committa await stopRecording(); }, onMeteringUpdate: (level: number) => { @@ -126,7 +127,6 @@ export function useVoiceChat() { onAuthenticationSuccess: (message: string) => { console.log('Autenticazione completata:', message); setState('setting_up'); - // Aspetta il messaggio "ready" prima di fare qualsiasi cosa }, onReady: () => { @@ -287,7 +287,8 @@ export function useVoiceChat() { }, [initialize]); /** - * Avvia la registrazione audio + * Avvia la registrazione audio con streaming chunks via WebSocket. + * Ogni chunk viene resampled a 24kHz e inviato in tempo reale. */ const startRecording = useCallback(async (): Promise => { if (!audioRecorderRef.current || !websocketRef.current) { @@ -301,7 +302,12 @@ export function useVoiceChat() { } try { - const started = await audioRecorderRef.current.startRecording(true, vadCallbacks); + // Callback invocato per ogni chunk audio resampled a 24kHz + const onChunk = (base64Chunk: string) => { + websocketRef.current?.sendAudio(base64Chunk); + }; + + const started = await audioRecorderRef.current.startRecording(true, vadCallbacks, onChunk); if (!started) { setError('Impossibile avviare la registrazione'); return false; @@ -329,7 +335,8 @@ export function useVoiceChat() { }, []); /** - * Ferma la registrazione e invia l'audio al server + * Ferma la registrazione e committa il buffer audio al server. + * I chunks sono gia' stati inviati in streaming durante la registrazione. */ const stopRecording = useCallback(async (): Promise => { if (!audioRecorderRef.current || !websocketRef.current) return false; @@ -341,30 +348,11 @@ export function useVoiceChat() { } try { - const pcm16Base64 = await audioRecorderRef.current.stopRecording(); - if (!pcm16Base64) { - console.error('❌ Nessun dato audio registrato'); - setError('Nessun dato audio registrato'); - setState('error'); - return false; - } + await audioRecorderRef.current.stopRecording(); - // Calcola la durata approssimativa dell'audio - const audioBytes = Math.floor(pcm16Base64.length * 0.75); - const durationMs = (audioBytes / 2) / 24; // 2 bytes per sample, 24 samples per ms - - console.log(`πŸ“¦ Audio pronto: ${durationMs.toFixed(0)}ms (${audioBytes} bytes)`); - - // Invia audio PCM16 - websocketRef.current.sendAudio(pcm16Base64); - console.log('πŸ“€ Audio inviato al server'); - - // IMPORTANTE: Aspetta 300ms per assicurare che il server processi l'audio - // prima di committare il buffer. Questo previene l'errore "buffer_empty" - await new Promise(resolve => setTimeout(resolve, 300)); - + // I chunks sono gia' stati inviati in streaming, committa il buffer websocketRef.current.sendAudioCommit(); - console.log('βœ… Buffer committato'); + console.log('Buffer committato (chunks inviati in streaming)'); setState('processing'); setRecordingDuration(0); diff --git a/src/services/botservice.ts b/src/services/botservice.ts index f7169c4..e6b0f1e 100644 --- a/src/services/botservice.ts +++ b/src/services/botservice.ts @@ -818,7 +818,12 @@ export class VoiceBotWebSocket { * Invia un chunk audio PCM16 base64 al server */ sendAudio(base64Pcm16Data: string): void { + console.log(`πŸ” DEBUG sendAudio: base64 length=${base64Pcm16Data.length}, approx bytes=${Math.floor(base64Pcm16Data.length * 0.75)}`); + console.log(`πŸ” DEBUG sendAudio: primi 50 chars base64="${base64Pcm16Data.substring(0, 50)}"`); + console.log(`πŸ” DEBUG sendAudio: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); const msg: VoiceAudioMessage = { type: 'audio', data: base64Pcm16Data }; + const jsonMsg = JSON.stringify(msg); + console.log(`πŸ” DEBUG sendAudio: JSON message size=${jsonMsg.length} bytes`); this.sendOrQueue(msg); } @@ -826,8 +831,10 @@ export class VoiceBotWebSocket { * Committa il buffer audio (opzionale - il server ha semantic VAD) */ sendAudioCommit(): void { + console.log(`πŸ” DEBUG sendAudioCommit: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); const msg: VoiceAudioCommitMessage = { type: 'audio_commit' }; this.sendOrQueue(msg); + console.log('πŸ” DEBUG sendAudioCommit: messaggio inviato'); } /** @@ -852,16 +859,20 @@ export class VoiceBotWebSocket { */ private sendOrQueue(message: VoiceClientMessage): void { if (!this.isConnected()) { + console.log(`πŸ” DEBUG sendOrQueue: BLOCCATO - non connesso (type=${message.type})`); this.callbacks.onError?.('Connessione WebSocket non disponibile'); return; } if (!this.isReady()) { + console.log(`πŸ” DEBUG sendOrQueue: IN CODA - non pronto (type=${message.type}, authState=${this.authState})`); this.messageQueue.push(message); return; } - this.ws!.send(JSON.stringify(message)); + const json = JSON.stringify(message); + console.log(`πŸ” DEBUG sendOrQueue: INVIATO type=${message.type}, size=${json.length} bytes`); + this.ws!.send(json); } /** diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index ea03828..11bfb14 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -1,9 +1,12 @@ import { Audio } from 'expo-av'; import * as FileSystem from 'expo-file-system'; +import ExpoAudioStudio from 'expo-audio-studio'; /** * Utility per la gestione dell'audio nella chat vocale - * Supporta il formato PCM16 a 24kHz richiesto dall'OpenAI Realtime API + * Registrazione: expo-audio-studio (streaming chunks PCM16 base64) + * Riproduzione: expo-av (playback WAV) + * Server richiede PCM16 a 24kHz, expo-audio-studio registra a 16kHz -> resample necessario */ /** @@ -71,19 +74,17 @@ function encodeBase64(bytes: Uint8Array): string { // Configurazioni audio per OpenAI Realtime API export const AUDIO_CONFIG = { - SAMPLE_RATE: 24000, + SAMPLE_RATE: 24000, // Target sample rate richiesto dal server + SOURCE_SAMPLE_RATE: 16000, // Sample rate di expo-audio-studio (fisso) CHANNELS: 1, BIT_DEPTH: 16, MAX_RECORDING_TIME: 300000, // 5 minuti per sessioni conversazionali }; -// Configurazioni VAD (Voice Activity Detection) - solo per feedback UI -// Il server gestisce il turn detection con semantic VAD +// Configurazioni VAD (Voice Activity Detection) - expo-audio-studio ha VAD nativo export const VAD_CONFIG = { - SPEECH_THRESHOLD_DB: -60, - SILENCE_THRESHOLD_DB: -70, + VOICE_ACTIVITY_THRESHOLD: 0.5, // Sensibilita' VAD (0.0-1.0) SILENCE_DURATION_MS: 1200, - METERING_POLL_INTERVAL_MS: 100, MIN_RECORDING_DURATION_MS: 500, }; @@ -93,6 +94,46 @@ export const AUDIO_LEVEL_CONFIG = { MAX_DB: -10, }; +/** + * Resample PCM16 da 16kHz a 24kHz con interpolazione lineare. + * Input/output: Uint8Array di campioni Int16 little-endian. + * Rapporto 16000:24000 = 2:3 + */ +function resample16to24(pcm16Data: Uint8Array): Uint8Array { + const srcSamples = pcm16Data.length / 2; // 2 bytes per sample (Int16) + if (srcSamples === 0) return new Uint8Array(0); + + const srcView = new DataView(pcm16Data.buffer, pcm16Data.byteOffset, pcm16Data.byteLength); + + // Rapporto: per ogni sample di output, calcola la posizione nel sorgente + const ratio = AUDIO_CONFIG.SOURCE_SAMPLE_RATE / AUDIO_CONFIG.SAMPLE_RATE; // 16000/24000 = 0.6667 + const dstSamples = Math.floor(srcSamples / ratio); + const dstBuffer = new Uint8Array(dstSamples * 2); + const dstView = new DataView(dstBuffer.buffer); + + for (let i = 0; i < dstSamples; i++) { + const srcPos = i * ratio; + const srcIndex = Math.floor(srcPos); + const frac = srcPos - srcIndex; + + // Leggi campione corrente + const sample0 = srcView.getInt16(srcIndex * 2, true); + + if (srcIndex + 1 < srcSamples && frac > 0) { + // Interpolazione lineare tra due campioni + const sample1 = srcView.getInt16((srcIndex + 1) * 2, true); + const interpolated = Math.round(sample0 + frac * (sample1 - sample0)); + // Clamp a Int16 range + const clamped = Math.max(-32768, Math.min(32767, interpolated)); + dstView.setInt16(i * 2, clamped, true); + } else { + dstView.setInt16(i * 2, sample0, true); + } + } + + return dstBuffer; +} + /** * Crea un header WAV per dati audio PCM16. * Necessario perche' expo-av richiede un formato file per la riproduzione. @@ -144,18 +185,6 @@ export function wrapPcm16InWav(pcm16Data: Uint8Array, sampleRate: number = 24000 return wav; } -/** - * Rimuove l'header WAV (44 bytes) da dati audio, restituendo PCM16 raw. - */ -function stripWavHeader(bytes: Uint8Array): Uint8Array { - if (bytes.length > 44 && - bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && - bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) { - return bytes.subarray(44); - } - return bytes; -} - /** * Callback per eventi VAD */ @@ -168,93 +197,108 @@ export interface VADCallbacks { } /** - * Opzioni di registrazione per PCM16 WAV a 24kHz - */ -function getPcm16RecordingOptions(enableMetering: boolean) { - return { - isMeteringEnabled: enableMetering, - android: { - extension: '.wav', - outputFormat: 0, // AndroidOutputFormat.DEFAULT - audioEncoder: 0, // AndroidAudioEncoder.DEFAULT - sampleRate: AUDIO_CONFIG.SAMPLE_RATE, - numberOfChannels: AUDIO_CONFIG.CHANNELS, - bitRate: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, - }, - ios: { - extension: '.wav', - outputFormat: Audio.IOSOutputFormat.LINEARPCM, - audioQuality: Audio.IOSAudioQuality.HIGH, - sampleRate: AUDIO_CONFIG.SAMPLE_RATE, - numberOfChannels: AUDIO_CONFIG.CHANNELS, - bitRate: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, - linearPCMBitDepth: AUDIO_CONFIG.BIT_DEPTH, - linearPCMIsBigEndian: false, - linearPCMIsFloat: false, - }, - web: { - mimeType: 'audio/wav', - bitsPerSecond: AUDIO_CONFIG.SAMPLE_RATE * AUDIO_CONFIG.BIT_DEPTH * AUDIO_CONFIG.CHANNELS, - }, - }; -} - -/** - * Classe per gestire la registrazione audio in formato PCM16 + * Classe per gestire la registrazione audio con expo-audio-studio. + * Fornisce streaming di chunks PCM16 base64 resampled a 24kHz. */ export class AudioRecorder { - private recording: Audio.Recording | null = null; private isRecording: boolean = false; private recordingStartTime: number = 0; - - // VAD properties - private meteringInterval: NodeJS.Timeout | null = null; - private silenceStartTime: number | null = null; + private onChunkCallback: ((base64Chunk: string) => void) | null = null; + private chunkSubscription: { remove: () => void } | null = null; + private vadSubscription: { remove: () => void } | null = null; + private statusSubscription: { remove: () => void } | null = null; private vadEnabled: boolean = false; private vadCallbacks: VADCallbacks = {}; private isSpeechDetected: boolean = false; + private silenceStartTime: number | null = null; /** - * Avvia la registrazione audio in formato PCM16 WAV a 24kHz + * Avvia la registrazione audio con streaming chunks. + * Ogni chunk viene resampled da 16kHz a 24kHz e inviato come base64 via onChunk callback. */ - async startRecording(enableVAD: boolean = false, vadCallbacks?: VADCallbacks): Promise { + async startRecording( + enableVAD: boolean = false, + vadCallbacks?: VADCallbacks, + onChunk?: (base64Chunk: string) => void + ): Promise { try { - const { granted } = await Audio.requestPermissionsAsync(); - if (!granted) { - console.error('Permessi microfono non concessi'); - return false; - } + this.onChunkCallback = onChunk || null; + this.vadEnabled = enableVAD; + this.vadCallbacks = vadCallbacks || {}; + this.isSpeechDetected = false; + this.silenceStartTime = null; - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - staysActiveInBackground: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: false, + // Abilita streaming di chunks base64 + ExpoAudioStudio.setListenToChunks(true); + + // Sottoscrivi ai chunks audio: PCM16@16kHz in base64 + this.chunkSubscription = ExpoAudioStudio.addListener('onAudioChunk', (event: { base64: string }) => { + if (!this.isRecording || !this.onChunkCallback) return; + + try { + // Decodifica base64 -> PCM16 raw bytes @16kHz + const pcm16at16k = decodeBase64(event.base64); + + // Resample 16kHz -> 24kHz + const pcm16at24k = resample16to24(pcm16at16k); + + // Encode back to base64 e invia + const resampled64 = encodeBase64(pcm16at24k); + this.onChunkCallback(resampled64); + } catch (error) { + console.error('Errore processamento chunk audio:', error); + } }); - const recordingOptions = getPcm16RecordingOptions(enableVAD); - const { recording } = await Audio.Recording.createAsync(recordingOptions); - this.recording = recording; + // Sottoscrivi allo stato del recorder per metering + this.statusSubscription = ExpoAudioStudio.addListener('onRecorderAmplitude', (event: { amplitude: number }) => { + if (this.vadCallbacks.onMeteringUpdate) { + // amplitude e' in dB + this.vadCallbacks.onMeteringUpdate(event.amplitude); + } + }); - // Verifica che il recorder nativo sia effettivamente attivo - const status = await this.recording.getStatusAsync(); - if (!status.isRecording) { - console.error('Il recorder nativo non Γ¨ partito correttamente'); - this.cleanup(); - return false; + // Configura e abilita VAD nativo se richiesto + if (enableVAD) { + ExpoAudioStudio.setVADEnabled(true); + ExpoAudioStudio.setVoiceActivityThreshold(VAD_CONFIG.VOICE_ACTIVITY_THRESHOLD); + ExpoAudioStudio.setVADEventMode('onChange'); + + this.vadSubscription = ExpoAudioStudio.addListener('onVoiceActivityDetected', (event: { + isVoiceDetected: boolean; + confidence: number; + isStateChange: boolean; + eventType: string; + }) => { + if (!this.isRecording) return; + + const recordingDuration = this.getRecordingDuration(); + if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) return; + + if (event.eventType === 'speech_start') { + this.isSpeechDetected = true; + this.silenceStartTime = null; + this.vadCallbacks.onSpeechStart?.(); + } else if (event.eventType === 'silence_start') { + this.vadCallbacks.onSilenceDetected?.(); + this.silenceStartTime = Date.now(); + } else if (event.eventType === 'silence_continue' && this.isSpeechDetected && this.silenceStartTime) { + const silenceDuration = Date.now() - this.silenceStartTime; + if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS) { + this.vadCallbacks.onAutoStop?.(); + this.vadCallbacks.onSpeechEnd?.(); + } + } + }); } + // Avvia la registrazione + await ExpoAudioStudio.startRecording(); + this.isRecording = true; this.recordingStartTime = Date.now(); - this.vadEnabled = enableVAD; - this.vadCallbacks = vadCallbacks || {}; - - if (enableVAD) { - this.startVADMonitoring(); - } - console.log('Registrazione PCM16 avviata', enableVAD ? '(VAD attivo)' : ''); + console.log('Registrazione expo-audio-studio avviata', enableVAD ? '(VAD attivo)' : '', '- streaming chunks a 24kHz'); return true; } catch (error) { @@ -265,28 +309,20 @@ export class AudioRecorder { } /** - * Ferma la registrazione e restituisce i dati PCM16 raw in base64 + * Ferma la registrazione. I chunks sono gia' stati inviati in streaming. + * Restituisce null (non serve piu' il blob completo). */ async stopRecording(): Promise { - if (!this.recording || !this.isRecording) { + if (!this.isRecording) { console.warn('Nessuna registrazione attiva'); return null; } try { - await this.recording.stopAndUnloadAsync(); - const uri = this.recording.getURI(); - - if (!uri) { - console.error('URI della registrazione non disponibile'); - return null; - } - - const base64Data = await this.convertToRawPcm16Base64(uri); + await ExpoAudioStudio.stopRecording(); this.isRecording = false; console.log('Registrazione completata'); - return base64Data; - + return null; // I chunks sono gia' stati inviati in streaming } catch (error) { console.error('Errore stop registrazione:', error); return null; @@ -295,37 +331,6 @@ export class AudioRecorder { } } - /** - * Legge il file audio e restituisce PCM16 raw (senza header WAV) in base64 - */ - private async convertToRawPcm16Base64(audioUri: string): Promise { - try { - const fileInfo = await FileSystem.getInfoAsync(audioUri); - if (!fileInfo.exists) { - console.error('File audio non esiste:', audioUri); - return null; - } - - const base64Data = await FileSystem.readAsStringAsync(audioUri, { - encoding: FileSystem.EncodingType.Base64, - }); - - if (!base64Data) return null; - - // Decodifica, rimuovi header WAV se presente, riencodifica - const fullBytes = decodeBase64(base64Data); - const pcm16Bytes = stripWavHeader(fullBytes); - const pcm16Base64 = encodeBase64(pcm16Bytes); - - console.log(`Audio convertito: ${fullBytes.length} bytes -> ${pcm16Bytes.length} bytes PCM16 raw`); - return pcm16Base64; - - } catch (error) { - console.error('Errore conversione PCM16:', error); - return null; - } - } - getRecordingDuration(): number { if (!this.isRecording) return 0; return Date.now() - this.recordingStartTime; @@ -336,9 +341,9 @@ export class AudioRecorder { } async cancelRecording(): Promise { - if (this.recording && this.isRecording) { + if (this.isRecording) { try { - await this.recording.stopAndUnloadAsync(); + await ExpoAudioStudio.stopRecording(); } catch (error) { console.error('Errore cancellazione registrazione:', error); } @@ -346,73 +351,28 @@ export class AudioRecorder { this.cleanup(); } - private startVADMonitoring(): void { - this.meteringInterval = setInterval(async () => { - if (!this.recording || !this.isRecording) { - this.stopVADMonitoring(); - return; - } - - try { - const status = await this.recording.getStatusAsync(); - - if (status.isRecording && status.metering !== undefined) { - const meteringDB = status.metering; - this.vadCallbacks.onMeteringUpdate?.(meteringDB); - this.processMeteringLevel(meteringDB); - } - } catch (error) { - // Il recorder nativo non Γ¨ piΓΉ disponibile, ferma il monitoraggio - console.warn('VAD: recorder non disponibile, monitoraggio fermato'); - this.stopVADMonitoring(); - } - }, VAD_CONFIG.METERING_POLL_INTERVAL_MS); - } - - private stopVADMonitoring(): void { - if (this.meteringInterval) { - clearInterval(this.meteringInterval); - this.meteringInterval = null; + private cleanup(): void { + if (this.chunkSubscription) { + this.chunkSubscription.remove(); + this.chunkSubscription = null; } - } - - private processMeteringLevel(meteringDB: number): void { - const recordingDuration = this.getRecordingDuration(); - - if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) { - return; + if (this.vadSubscription) { + this.vadSubscription.remove(); + this.vadSubscription = null; } - - // Rilevamento voce - if (meteringDB > VAD_CONFIG.SPEECH_THRESHOLD_DB) { - if (!this.isSpeechDetected) { - this.isSpeechDetected = true; - this.vadCallbacks.onSpeechStart?.(); - } - if (this.silenceStartTime) { - this.silenceStartTime = null; - } + if (this.statusSubscription) { + this.statusSubscription.remove(); + this.statusSubscription = null; } - // Rilevamento silenzio - else if (meteringDB < VAD_CONFIG.SILENCE_THRESHOLD_DB) { - if (!this.silenceStartTime) { - this.silenceStartTime = Date.now(); - this.vadCallbacks.onSilenceDetected?.(); - } else { - const silenceDuration = Date.now() - this.silenceStartTime; - if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS && this.isSpeechDetected) { - this.vadCallbacks.onAutoStop?.(); - this.vadCallbacks.onSpeechEnd?.(); - } - } + + ExpoAudioStudio.setListenToChunks(false); + if (this.vadEnabled) { + ExpoAudioStudio.setVADEnabled(false); } - } - private cleanup(): void { - this.stopVADMonitoring(); - this.recording = null; this.isRecording = false; this.recordingStartTime = 0; + this.onChunkCallback = null; this.silenceStartTime = null; this.vadEnabled = false; this.isSpeechDetected = false; @@ -421,6 +381,7 @@ export class AudioRecorder { /** * Classe per gestire la riproduzione di chunk audio PCM16 + * Usa expo-av per il playback */ export class AudioPlayer { private currentSound: Audio.Sound | null = null; @@ -611,7 +572,7 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string { */ export function base64ToArrayBuffer(base64: string): ArrayBuffer { const bytes = decodeBase64(base64); - return bytes.buffer; + return bytes.buffer as ArrayBuffer; } /** From 317c490657a257dca2066900b339e8833e466817 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 2 Feb 2026 10:41:21 +0100 Subject: [PATCH 05/29] feat: migrate audio processing from expo-audio-studio to @picovoice/react-native-voice-processor - Added @picovoice/react-native-voice-processor for real-time audio streaming. - Updated useVoiceChat hook to reflect changes in audio processing method. - Refactored audioUtils to support new voice processor and removed resampling logic. - Implemented VAD based on RMS energy analysis instead of native VAD. - Adjusted AudioRecorder class to handle new audio processing and VAD logic. --- app.json | 8 +- package-lock.json | 396 +++++++++++++------------------------- package.json | 4 +- src/hooks/useVoiceChat.ts | 4 +- src/utils/audioUtils.ts | 239 +++++++++++------------ 5 files changed, 261 insertions(+), 390 deletions(-) diff --git a/app.json b/app.json index b003d50..9872283 100644 --- a/app.json +++ b/app.json @@ -75,13 +75,7 @@ ], "expo-dev-client", "expo-router", - "expo-audio", - [ - "expo-audio-studio", - { - "microphonePermission": "Allow MyTaskly to access your microphone for voice chat" - } - ] + "expo-audio" ], "extra": { "eas": { diff --git a/package-lock.json b/package-lock.json index 34963b4..f0617ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@expo/vector-icons": "^14.0.2", "@flyerhq/react-native-chat-ui": "^1.4.3", "@openspacelabs/react-native-zoomable-view": "^2.3.1", + "@picovoice/react-native-voice-processor": "^1.2.3", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "8.4.1", "@react-native-google-signin/google-signin": "^15.0.0", @@ -27,7 +28,6 @@ "eventsource": "^4.0.0", "expo": "~53.0.23", "expo-audio": "~0.4.9", - "expo-audio-studio": "^1.3.1", "expo-av": "^15.1.7", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", @@ -77,7 +77,7 @@ }, "devDependencies": { "@babel/core": "^7.25.2", - "@react-native-community/cli": "^18.0.0", + "@react-native-community/cli": "^14.0.1", "@types/jest": "^29.5.12", "@types/react": "~19.0.10", "@types/react-test-renderer": "^18.3.0", @@ -3601,6 +3601,19 @@ "react-native": ">=0.54.0" } }, + "node_modules/@picovoice/react-native-voice-processor": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@picovoice/react-native-voice-processor/-/react-native-voice-processor-1.2.3.tgz", + "integrity": "sha512-GFnuKXWIOrDTPumcFkwfGaGUt2X1Vq31cU0sM4CZ9o/SomZQxJml12nr8d4uxjG03Z/eouWGN/0AcxZPdqihlw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3657,18 +3670,19 @@ } }, "node_modules/@react-native-community/cli": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-18.0.1.tgz", - "integrity": "sha512-nEEYwfyP00j9i4nr/HhwPabDscoBhhCjxDBpcKERi2oTYHcBr3FTu9PV1gbeJCa4vRCHr6b6VgOcVTdy99NddQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-14.0.1.tgz", + "integrity": "sha512-QxBbQmZhhDZKOGTIjPr0cDrFcVrxlzFG1BmOuhok3x4xUk09B3zyIl8xnaqQ53YIVhXQHS0BX0f0evuKBcbcqQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-clean": "18.0.1", - "@react-native-community/cli-config": "18.0.1", - "@react-native-community/cli-doctor": "18.0.1", - "@react-native-community/cli-server-api": "18.0.1", - "@react-native-community/cli-tools": "18.0.1", - "@react-native-community/cli-types": "18.0.1", + "@react-native-community/cli-clean": "14.0.1", + "@react-native-community/cli-config": "14.0.1", + "@react-native-community/cli-debugger-ui": "14.0.1", + "@react-native-community/cli-doctor": "14.0.1", + "@react-native-community/cli-server-api": "14.0.1", + "@react-native-community/cli-tools": "14.0.1", + "@react-native-community/cli-types": "14.0.1", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", @@ -3687,26 +3701,26 @@ } }, "node_modules/@react-native-community/cli-clean": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-18.0.1.tgz", - "integrity": "sha512-brXqk//lmA2Vs5lGq9YLhk7X4IYBSrDRte6t1AktouJpCrH4Tp1sl45yJDS2CHOi/OY1oOfI3kA61tXNX5a/5A==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-14.0.1.tgz", + "integrity": "sha512-vf6dEwq0WmsQu2BViSZI8ascTdk7J4o82FRqpHiYSZkQau6eRmYFrgLoC5hg985Hp/HD+3wRSDW+lB/2+fz3yA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "18.0.1", + "@react-native-community/cli-tools": "14.0.1", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "node_modules/@react-native-community/cli-config": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-18.0.1.tgz", - "integrity": "sha512-O4DDJVMx+DYfwEgF/6lB4hoI9sVjrYW6AlLqeJY/D2XH2e4yqK/Pr3SAi4sOMgvjvYZKzLHqIQVxx54v+LyMQA==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-14.0.1.tgz", + "integrity": "sha512-yiyBdrsqZmaTtb5XFcfCwFavPagw7UxQVKnRBNVdwQNQHsRqhhPWZ0c7W+sESTMC/L9T0zvr7k4dxZ6TJEqqKQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "18.0.1", + "@react-native-community/cli-tools": "14.0.1", "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", @@ -3714,44 +3728,28 @@ "joi": "^17.2.1" } }, - "node_modules/@react-native-community/cli-config-android": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-18.0.1.tgz", - "integrity": "sha512-1wzmGLfS7qgzm0ZfwX/f6Lat/af8/UYdjwtb3ap6RfKNclvIoap0wN6uBeiANmLfk0/BhoG8K1vKtIPwlU/V1A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@react-native-community/cli-tools": "18.0.1", - "chalk": "^4.1.2", - "fast-glob": "^3.3.2", - "fast-xml-parser": "^4.4.1" - } - }, - "node_modules/@react-native-community/cli-config-apple": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-18.0.1.tgz", - "integrity": "sha512-ybr1ZrOSd/Z+oCJ1qVSKVQauvneObTu3VjvYPhhrme7tUUSaYmd3iikaWonbKk5rVp+2WqOFR6Cy7XqVfwwG8A==", + "node_modules/@react-native-community/cli-debugger-ui": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-14.0.1.tgz", + "integrity": "sha512-8eQ1U21Uwwm71jmIi9iaiou2VxqJQTm9k7Ch5Nj1ZEErO+nKFc9qExPTGtzsE80mHncLq8yNkQfV5waJvvhclg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "18.0.1", - "chalk": "^4.1.2", - "execa": "^5.0.0", - "fast-glob": "^3.3.2" + "serve-static": "^1.13.1" } }, "node_modules/@react-native-community/cli-doctor": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-18.0.1.tgz", - "integrity": "sha512-B1UWpiVeJ45DX0ip1Et62knAHLzeA1B3XcTJu16PscednnNxV6GBH52kRUoWMsB8HQ8f9IWdFTol8LAp5Y0wwg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-14.0.1.tgz", + "integrity": "sha512-wXLa4cPgNuFJE6K2dv/0JRq62iJUiSVLN0Wa5TxugX+ojXBle/oe1W3nibOJFYdyRIEUGk3u7A6gdoznNtZEJg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config": "18.0.1", - "@react-native-community/cli-platform-android": "18.0.1", - "@react-native-community/cli-platform-apple": "18.0.1", - "@react-native-community/cli-platform-ios": "18.0.1", - "@react-native-community/cli-tools": "18.0.1", + "@react-native-community/cli-config": "14.0.1", + "@react-native-community/cli-platform-android": "14.0.1", + "@react-native-community/cli-platform-apple": "14.0.1", + "@react-native-community/cli-platform-ios": "14.0.1", + "@react-native-community/cli-tools": "14.0.1", "chalk": "^4.1.2", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", @@ -3760,10 +3758,21 @@ "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "semver": "^7.5.2", + "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, + "node_modules/@react-native-community/cli-doctor/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@react-native-community/cli-doctor/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3777,80 +3786,94 @@ "node": ">=10" } }, + "node_modules/@react-native-community/cli-doctor/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@react-native-community/cli-platform-android": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-18.0.1.tgz", - "integrity": "sha512-DCltVWDR7jfZZG5MXREVKG0fmIr1b0irEhmdkk/R87dG6HJ8tGXWXnAa4Kap8bx2v6lKFXDW5QxNecyyLCOkVw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-14.0.1.tgz", + "integrity": "sha512-HNLIuD3ie8p1cLhsvfH19XPhjZjqZGX+zYlm4lpOKxPTSEzkaiAdOIMj5dVdN79zO1tmEawyyrwT4Jx5ddeUsQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-android": "18.0.1", - "@react-native-community/cli-tools": "18.0.1", + "@react-native-community/cli-tools": "14.0.1", "chalk": "^4.1.2", "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.2.4", "logkitty": "^0.7.1" } }, "node_modules/@react-native-community/cli-platform-apple": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-18.0.1.tgz", - "integrity": "sha512-7WxGXT/ui7VtyLVjx5rkYkkTMlbufI6p5BdRKjGp/zQDnDzs/0rle0JEHJxwgvs5VQnt+VOnHBMipkQAhydIqQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-14.0.1.tgz", + "integrity": "sha512-KY7zlDpzKfVzBuHWde7EXKgdkFFYl5Lp1o1ro8ie83sDzwSVTSyd5Pmb351SJABJuNFSuTzbyyMiuG+1BQ4hyg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-apple": "18.0.1", - "@react-native-community/cli-tools": "18.0.1", + "@react-native-community/cli-tools": "14.0.1", "chalk": "^4.1.2", "execa": "^5.0.0", - "fast-xml-parser": "^4.4.1" + "fast-glob": "^3.3.2", + "fast-xml-parser": "^4.2.4", + "ora": "^5.4.1" } }, "node_modules/@react-native-community/cli-platform-ios": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-18.0.1.tgz", - "integrity": "sha512-GtO1FB+xaz+vcHIdvl94AkD5B8Y+H8XHb6QwnYX+A3WwteGsHrR8iD/bLLcRtNPtLaAWMa/RgWJpgs4KG+eU4w==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-14.0.1.tgz", + "integrity": "sha512-9DKbMHf53A4ae2sJ0rZFqi2m7Z1NoaWZYhkWufO8tEXCKckBat4ddio/Ei6g0bQk8IKdLzHogQFMuU3jbT3LYA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-platform-apple": "18.0.1" + "@react-native-community/cli-platform-apple": "14.0.1" } }, "node_modules/@react-native-community/cli-server-api": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-18.0.1.tgz", - "integrity": "sha512-ZRy2IjEM4ljP05bZcnXho0sCxVGI/9SkWkLuzXl+cRu/4I8vLRleihn2GJCopg82QHLLrajUCHhpDKE8NJWcRw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-14.0.1.tgz", + "integrity": "sha512-8oKMcDH72vuX2pWv+N07NcBWoMOykK7cUKshCjSqupWNr1wGxHnJC4uFvLLAwpoewYlOQXMnU9Jn/9IWA6Sm2w==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "18.0.1", - "body-parser": "^1.20.3", + "@react-native-community/cli-debugger-ui": "14.0.1", + "@react-native-community/cli-tools": "14.0.1", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", - "open": "^6.2.0", "pretty-format": "^26.6.2", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "node_modules/@react-native-community/cli-tools": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-18.0.1.tgz", - "integrity": "sha512-WxWFXwfYhHR2eYiB4lkHZVC/PmIkRWeVHBQKmn0h1mecr3GrHYO4BzW1jpD5Xt6XZ9jojQ9wE5xrCqXjiMSAIQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-14.0.1.tgz", + "integrity": "sha512-4tksgruPliZlQmO6kfX1gUBNvDChoOcZgGt9r4KvvyFelCR3VUWEoJkvhKlDlSoaEtzBO2L426CTnFK4ED/yBQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "execa": "^5.0.0", "find-up": "^5.0.0", - "launch-editor": "^2.9.1", "mime": "^2.4.1", + "open": "^6.2.0", "ora": "^5.4.1", - "prompts": "^2.4.2", - "semver": "^7.5.2" + "semver": "^7.5.2", + "shell-quote": "^1.7.3", + "sudo-prompt": "^9.0.0" } }, "node_modules/@react-native-community/cli-tools/node_modules/semver": { @@ -3867,9 +3890,9 @@ } }, "node_modules/@react-native-community/cli-types": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-18.0.1.tgz", - "integrity": "sha512-pGxr/TSP9Xiw2+9TUn3OWLdcuI4+PJozPsCYZVTGWJ96X6Pv7YX/rNy4emIDkaWaFZ7IWgWXUA725KhEINSf3Q==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-14.0.1.tgz", + "integrity": "sha512-Yk6TLdkSQHaSG9NlcMsHtX7ZYuHaOrZ7K/3zBbpabWUm006blND5NKw8YQuk9guqK7i/PMSUKJUq1NbF7bGjDA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5395,13 +5418,6 @@ "@urql/core": "^5.0.0" } }, - "node_modules/@vscode/sudo-prompt": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", - "integrity": "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -6466,48 +6482,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "devOptional": true, - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -7146,16 +7120,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7873,9 +7837,9 @@ } }, "node_modules/envinfo": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.20.0.tgz", - "integrity": "sha512-+zUomDcLXsVkQ37vUqWBvQwLaLlj8eZPSi61llaEFAVBY5mhcXdaSw1pSJVl4yTYD5g/gEfpNl28YYk4IPvrrg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", "devOptional": true, "license": "MIT", "bin": { @@ -7904,17 +7868,21 @@ } }, "node_modules/errorhandler": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.1.tgz", - "integrity": "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", "devOptional": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.7", + "accepts": "~1.3.8", "escape-html": "~1.0.3" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/es-abstract": { @@ -8747,20 +8715,6 @@ "react-native": "*" } }, - "node_modules/expo-audio-studio": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/expo-audio-studio/-/expo-audio-studio-1.3.1.tgz", - "integrity": "sha512-eEN+xG30ZlfcTilOMl6cJKxGSJAbObCpkvPxyqI2r0unDJxC0RhHpSqAkr2af7n/zorsvnm1n+8TS+g7X7ap6Q==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-av": { "version": "15.1.7", "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-15.1.7.tgz", @@ -10226,19 +10180,6 @@ } } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -12425,17 +12366,6 @@ "lan-network": "dist/lan-network-cli.js" } }, - "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -12758,9 +12688,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -13130,16 +13060,6 @@ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", "license": "MIT" }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -13983,9 +13903,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -14116,7 +14036,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14901,22 +14821,6 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "devOptional": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -14992,22 +14896,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -16194,7 +16082,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -16599,7 +16487,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16619,7 +16507,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16636,7 +16524,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16655,7 +16543,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -17298,6 +17186,14 @@ "node": ">= 6" } }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "devOptional": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17358,9 +17254,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -17761,20 +17657,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -18037,9 +17919,9 @@ } }, "node_modules/undici": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", - "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index bf0a3a3..8554fa1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@expo/vector-icons": "^14.0.2", "@flyerhq/react-native-chat-ui": "^1.4.3", "@openspacelabs/react-native-zoomable-view": "^2.3.1", + "@picovoice/react-native-voice-processor": "^1.2.3", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "8.4.1", "@react-native-google-signin/google-signin": "^15.0.0", @@ -37,7 +38,6 @@ "eventsource": "^4.0.0", "expo": "~53.0.23", "expo-audio": "~0.4.9", - "expo-audio-studio": "^1.3.1", "expo-av": "^15.1.7", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", @@ -87,7 +87,7 @@ }, "devDependencies": { "@babel/core": "^7.25.2", - "@react-native-community/cli": "^18.0.0", + "@react-native-community/cli": "^14.0.1", "@types/jest": "^29.5.12", "@types/react": "~19.0.10", "@types/react-test-renderer": "^18.3.0", diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index a7de9d0..96ed0c4 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -45,7 +45,7 @@ export interface ActiveTool { /** * Hook personalizzato per la gestione della chat vocale * Compatibile con l'OpenAI Realtime API tramite WebSocket - * Usa expo-audio-studio per streaming chunks PCM16 base64 in tempo reale + * Usa @picovoice/react-native-voice-processor per streaming PCM16 base64 in tempo reale a 24kHz */ export function useVoiceChat() { // Stati principali @@ -288,7 +288,7 @@ export function useVoiceChat() { /** * Avvia la registrazione audio con streaming chunks via WebSocket. - * Ogni chunk viene resampled a 24kHz e inviato in tempo reale. + * Ogni frame PCM16 a 24kHz viene inviato in tempo reale. */ const startRecording = useCallback(async (): Promise => { if (!audioRecorderRef.current || !websocketRef.current) { diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index 11bfb14..29252bc 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -1,12 +1,12 @@ import { Audio } from 'expo-av'; import * as FileSystem from 'expo-file-system'; -import ExpoAudioStudio from 'expo-audio-studio'; +import { VoiceProcessor } from '@picovoice/react-native-voice-processor'; /** * Utility per la gestione dell'audio nella chat vocale - * Registrazione: expo-audio-studio (streaming chunks PCM16 base64) + * Registrazione: @picovoice/react-native-voice-processor (streaming frames PCM16) * Riproduzione: expo-av (playback WAV) - * Server richiede PCM16 a 24kHz, expo-audio-studio registra a 16kHz -> resample necessario + * Server richiede PCM16 a 24kHz, VoiceProcessor registra a 16kHz -> resample necessario */ /** @@ -74,14 +74,14 @@ function encodeBase64(bytes: Uint8Array): string { // Configurazioni audio per OpenAI Realtime API export const AUDIO_CONFIG = { - SAMPLE_RATE: 24000, // Target sample rate richiesto dal server - SOURCE_SAMPLE_RATE: 16000, // Sample rate di expo-audio-studio (fisso) + SAMPLE_RATE: 24000, // Sample rate registrazione e server (VoiceProcessor supporta 24kHz diretto) CHANNELS: 1, BIT_DEPTH: 16, + FRAME_LENGTH: 1024, // Numero di campioni per frame (VoiceProcessor) MAX_RECORDING_TIME: 300000, // 5 minuti per sessioni conversazionali }; -// Configurazioni VAD (Voice Activity Detection) - expo-audio-studio ha VAD nativo +// Configurazioni VAD (Voice Activity Detection) - implementata via analisi energia RMS export const VAD_CONFIG = { VOICE_ACTIVITY_THRESHOLD: 0.5, // Sensibilita' VAD (0.0-1.0) SILENCE_DURATION_MS: 1200, @@ -95,43 +95,33 @@ export const AUDIO_LEVEL_CONFIG = { }; /** - * Resample PCM16 da 16kHz a 24kHz con interpolazione lineare. - * Input/output: Uint8Array di campioni Int16 little-endian. - * Rapporto 16000:24000 = 2:3 + * Converte un array di campioni Int16 (number[]) in Uint8Array little-endian PCM16. + * VoiceProcessor fornisce frames come number[], il server richiede PCM16 bytes. */ -function resample16to24(pcm16Data: Uint8Array): Uint8Array { - const srcSamples = pcm16Data.length / 2; // 2 bytes per sample (Int16) - if (srcSamples === 0) return new Uint8Array(0); - - const srcView = new DataView(pcm16Data.buffer, pcm16Data.byteOffset, pcm16Data.byteLength); - - // Rapporto: per ogni sample di output, calcola la posizione nel sorgente - const ratio = AUDIO_CONFIG.SOURCE_SAMPLE_RATE / AUDIO_CONFIG.SAMPLE_RATE; // 16000/24000 = 0.6667 - const dstSamples = Math.floor(srcSamples / ratio); - const dstBuffer = new Uint8Array(dstSamples * 2); - const dstView = new DataView(dstBuffer.buffer); - - for (let i = 0; i < dstSamples; i++) { - const srcPos = i * ratio; - const srcIndex = Math.floor(srcPos); - const frac = srcPos - srcIndex; - - // Leggi campione corrente - const sample0 = srcView.getInt16(srcIndex * 2, true); - - if (srcIndex + 1 < srcSamples && frac > 0) { - // Interpolazione lineare tra due campioni - const sample1 = srcView.getInt16((srcIndex + 1) * 2, true); - const interpolated = Math.round(sample0 + frac * (sample1 - sample0)); - // Clamp a Int16 range - const clamped = Math.max(-32768, Math.min(32767, interpolated)); - dstView.setInt16(i * 2, clamped, true); - } else { - dstView.setInt16(i * 2, sample0, true); - } +function int16ArrayToBytes(samples: number[]): Uint8Array { + const buffer = new Uint8Array(samples.length * 2); + const view = new DataView(buffer.buffer); + for (let i = 0; i < samples.length; i++) { + view.setInt16(i * 2, samples[i], true); // little-endian } + return buffer; +} - return dstBuffer; +/** + * Calcola il livello RMS in dB da un array di campioni Int16. + * Usato per metering e per la VAD basata su energia. + */ +function computeRmsDb(samples: number[]): number { + if (samples.length === 0) return AUDIO_LEVEL_CONFIG.MIN_DB; + let sumSquares = 0; + for (let i = 0; i < samples.length; i++) { + const normalized = samples[i] / 32768; + sumSquares += normalized * normalized; + } + const rms = Math.sqrt(sumSquares / samples.length); + if (rms === 0) return AUDIO_LEVEL_CONFIG.MIN_DB; + const db = 20 * Math.log10(rms); + return Math.max(AUDIO_LEVEL_CONFIG.MIN_DB, Math.min(AUDIO_LEVEL_CONFIG.MAX_DB, db)); } /** @@ -197,24 +187,25 @@ export interface VADCallbacks { } /** - * Classe per gestire la registrazione audio con expo-audio-studio. - * Fornisce streaming di chunks PCM16 base64 resampled a 24kHz. + * Classe per gestire la registrazione audio con @picovoice/react-native-voice-processor. + * Fornisce streaming di chunks PCM16 base64 a 24kHz direttamente (nessun resampling). + * VAD implementata tramite analisi energia RMS dei frames. */ export class AudioRecorder { private isRecording: boolean = false; private recordingStartTime: number = 0; private onChunkCallback: ((base64Chunk: string) => void) | null = null; - private chunkSubscription: { remove: () => void } | null = null; - private vadSubscription: { remove: () => void } | null = null; - private statusSubscription: { remove: () => void } | null = null; + private frameListener: ((frame: number[]) => void) | null = null; + private errorListener: ((error: any) => void) | null = null; private vadEnabled: boolean = false; private vadCallbacks: VADCallbacks = {}; private isSpeechDetected: boolean = false; private silenceStartTime: number | null = null; + private voiceProcessor: VoiceProcessor = VoiceProcessor.instance; /** - * Avvia la registrazione audio con streaming chunks. - * Ogni chunk viene resampled da 16kHz a 24kHz e inviato come base64 via onChunk callback. + * Avvia la registrazione audio con streaming frames. + * VoiceProcessor registra direttamente a 24kHz, ogni frame viene convertito in PCM16 base64. */ async startRecording( enableVAD: boolean = false, @@ -228,77 +219,52 @@ export class AudioRecorder { this.isSpeechDetected = false; this.silenceStartTime = null; - // Abilita streaming di chunks base64 - ExpoAudioStudio.setListenToChunks(true); + // Verifica permessi microfono + if (!(await this.voiceProcessor.hasRecordAudioPermission())) { + console.error('Permesso microfono non concesso'); + return false; + } - // Sottoscrivi ai chunks audio: PCM16@16kHz in base64 - this.chunkSubscription = ExpoAudioStudio.addListener('onAudioChunk', (event: { base64: string }) => { - if (!this.isRecording || !this.onChunkCallback) return; + // Listener per i frames audio: number[] di campioni Int16 @ 24kHz + this.frameListener = (frame: number[]) => { + if (!this.isRecording) return; try { - // Decodifica base64 -> PCM16 raw bytes @16kHz - const pcm16at16k = decodeBase64(event.base64); + // Converti campioni Int16 in bytes PCM16 little-endian + const pcm16Bytes = int16ArrayToBytes(frame); + + // Encode in base64 e invia + const base64Chunk = encodeBase64(pcm16Bytes); + this.onChunkCallback?.(base64Chunk); - // Resample 16kHz -> 24kHz - const pcm16at24k = resample16to24(pcm16at16k); + // Calcola livello audio per metering + const rmsDb = computeRmsDb(frame); + this.vadCallbacks.onMeteringUpdate?.(rmsDb); - // Encode back to base64 e invia - const resampled64 = encodeBase64(pcm16at24k); - this.onChunkCallback(resampled64); + // VAD basata su energia RMS + if (this.vadEnabled) { + this.processVAD(rmsDb); + } } catch (error) { - console.error('Errore processamento chunk audio:', error); + console.error('Errore processamento frame audio:', error); } - }); + }; - // Sottoscrivi allo stato del recorder per metering - this.statusSubscription = ExpoAudioStudio.addListener('onRecorderAmplitude', (event: { amplitude: number }) => { - if (this.vadCallbacks.onMeteringUpdate) { - // amplitude e' in dB - this.vadCallbacks.onMeteringUpdate(event.amplitude); - } - }); + // Listener per errori + this.errorListener = (error: any) => { + console.error('VoiceProcessor errore:', error); + }; - // Configura e abilita VAD nativo se richiesto - if (enableVAD) { - ExpoAudioStudio.setVADEnabled(true); - ExpoAudioStudio.setVoiceActivityThreshold(VAD_CONFIG.VOICE_ACTIVITY_THRESHOLD); - ExpoAudioStudio.setVADEventMode('onChange'); - - this.vadSubscription = ExpoAudioStudio.addListener('onVoiceActivityDetected', (event: { - isVoiceDetected: boolean; - confidence: number; - isStateChange: boolean; - eventType: string; - }) => { - if (!this.isRecording) return; - - const recordingDuration = this.getRecordingDuration(); - if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) return; - - if (event.eventType === 'speech_start') { - this.isSpeechDetected = true; - this.silenceStartTime = null; - this.vadCallbacks.onSpeechStart?.(); - } else if (event.eventType === 'silence_start') { - this.vadCallbacks.onSilenceDetected?.(); - this.silenceStartTime = Date.now(); - } else if (event.eventType === 'silence_continue' && this.isSpeechDetected && this.silenceStartTime) { - const silenceDuration = Date.now() - this.silenceStartTime; - if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS) { - this.vadCallbacks.onAutoStop?.(); - this.vadCallbacks.onSpeechEnd?.(); - } - } - }); - } + this.voiceProcessor.addFrameListener(this.frameListener); + this.voiceProcessor.addErrorListener(this.errorListener); - // Avvia la registrazione - await ExpoAudioStudio.startRecording(); + // Avvia la registrazione a 24kHz + await this.voiceProcessor.start(AUDIO_CONFIG.FRAME_LENGTH, AUDIO_CONFIG.SAMPLE_RATE); this.isRecording = true; this.recordingStartTime = Date.now(); - console.log('Registrazione expo-audio-studio avviata', enableVAD ? '(VAD attivo)' : '', '- streaming chunks a 24kHz'); + console.log('Registrazione VoiceProcessor avviata', enableVAD ? '(VAD attivo)' : '', '- streaming PCM16 a 24kHz'); return true; } catch (error) { @@ -308,6 +274,44 @@ export class AudioRecorder { } } + /** + * Processa VAD basata su energia RMS. + * Soglia: se il livello dB supera il threshold -> voce rilevata, altrimenti silenzio. + */ + private processVAD(rmsDb: number): void { + if (!this.isRecording) return; + + const recordingDuration = this.getRecordingDuration(); + if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) return; + + // Mappa la soglia VAD (0.0-1.0) a un range dB + const dbThreshold = AUDIO_LEVEL_CONFIG.MIN_DB + + VAD_CONFIG.VOICE_ACTIVITY_THRESHOLD * (AUDIO_LEVEL_CONFIG.MAX_DB - AUDIO_LEVEL_CONFIG.MIN_DB); + + const isVoice = rmsDb > dbThreshold; + + if (isVoice) { + if (!this.isSpeechDetected) { + this.isSpeechDetected = true; + this.vadCallbacks.onSpeechStart?.(); + } + this.silenceStartTime = null; + } else { + if (this.isSpeechDetected) { + if (this.silenceStartTime === null) { + this.silenceStartTime = Date.now(); + this.vadCallbacks.onSilenceDetected?.(); + } else { + const silenceDuration = Date.now() - this.silenceStartTime; + if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS) { + this.vadCallbacks.onAutoStop?.(); + this.vadCallbacks.onSpeechEnd?.(); + } + } + } + } + } + /** * Ferma la registrazione. I chunks sono gia' stati inviati in streaming. * Restituisce null (non serve piu' il blob completo). @@ -319,10 +323,10 @@ export class AudioRecorder { } try { - await ExpoAudioStudio.stopRecording(); + await this.voiceProcessor.stop(); this.isRecording = false; console.log('Registrazione completata'); - return null; // I chunks sono gia' stati inviati in streaming + return null; } catch (error) { console.error('Errore stop registrazione:', error); return null; @@ -343,7 +347,7 @@ export class AudioRecorder { async cancelRecording(): Promise { if (this.isRecording) { try { - await ExpoAudioStudio.stopRecording(); + await this.voiceProcessor.stop(); } catch (error) { console.error('Errore cancellazione registrazione:', error); } @@ -352,22 +356,13 @@ export class AudioRecorder { } private cleanup(): void { - if (this.chunkSubscription) { - this.chunkSubscription.remove(); - this.chunkSubscription = null; - } - if (this.vadSubscription) { - this.vadSubscription.remove(); - this.vadSubscription = null; + if (this.frameListener) { + this.voiceProcessor.removeFrameListener(this.frameListener); + this.frameListener = null; } - if (this.statusSubscription) { - this.statusSubscription.remove(); - this.statusSubscription = null; - } - - ExpoAudioStudio.setListenToChunks(false); - if (this.vadEnabled) { - ExpoAudioStudio.setVADEnabled(false); + if (this.errorListener) { + this.voiceProcessor.removeErrorListener(this.errorListener); + this.errorListener = null; } this.isRecording = false; From 3b24cde6bf63ff74c002520e2eb2cfd8c1f2e1ff Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 2 Feb 2026 11:17:11 +0100 Subject: [PATCH 06/29] refactor: simplify voice activity detection and related states in audio processing --- src/components/BotChat/VoiceChatModal.tsx | 15 ++- src/hooks/useVoiceChat.ts | 41 +-------- src/utils/audioUtils.ts | 106 +--------------------- 3 files changed, 11 insertions(+), 151 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 38741b2..afd7cec 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -36,7 +36,6 @@ const VoiceChatModal: React.FC = ({ isRecording, isProcessing, isSpeaking, - isSpeechActive, transcripts, activeTools, connect, @@ -103,7 +102,7 @@ const VoiceChatModal: React.FC = ({ // Animazione del cerchio pulsante - solo quando in ascolto useEffect(() => { - const shouldAnimate = isRecording && isSpeechActive; + const shouldAnimate = isRecording; if (shouldAnimate) { const pulseAnimation = Animated.loop( @@ -138,7 +137,7 @@ const VoiceChatModal: React.FC = ({ return () => pulseAnimation.stop(); } - }, [isRecording, isSpeechActive, pulseScale, pulseOpacity]); + }, [isRecording, pulseScale, pulseOpacity]); // Animazione durante elaborazione/risposta useEffect(() => { @@ -220,7 +219,7 @@ const VoiceChatModal: React.FC = ({ case 'error': return Qualcosa Γ¨ andato storto; case 'recording': - return {isSpeechActive ? 'Ti ascolto...' : 'Parla quando vuoi'}; + return Ti ascolto...; case 'processing': if (activeTools.some(t => t.status === 'running')) { return Sto eseguendo azioni...; @@ -301,15 +300,13 @@ const VoiceChatModal: React.FC = ({ } // Stato: ascolto attivo con animazione semplice - const isListening = isRecording && isSpeechActive; - return ( @@ -377,7 +374,7 @@ const VoiceChatModal: React.FC = ({ {/* Cerchio animato centrale */} {/* Cerchi di pulsazione - solo quando in ascolto */} - {(isRecording && isSpeechActive) && ( + {isRecording && ( <> ([]); const [activeTools, setActiveTools] = useState([]); - // VAD states (feedback UI) - const [audioLevel, setAudioLevel] = useState(-160); - const [isSpeechActive, setIsSpeechActive] = useState(false); - // Refs per gestire le istanze const websocketRef = useRef(null); const audioRecorderRef = useRef(null); @@ -93,28 +89,6 @@ export function useVoiceChat() { } }, []); - /** - * VAD Callbacks β€” per feedback UI + auto-stop su silenzio prolungato - */ - const vadCallbacks: VADCallbacks = { - onSpeechStart: () => { - setIsSpeechActive(true); - }, - onSpeechEnd: () => { - setIsSpeechActive(false); - }, - onSilenceDetected: () => { - setIsSpeechActive(false); - }, - onAutoStop: async () => { - // VAD rileva silenzio prolungato: ferma registrazione e committa - await stopRecording(); - }, - onMeteringUpdate: (level: number) => { - setAudioLevel(level); - }, - }; - /** * Callback per gestire i messaggi WebSocket */ @@ -302,12 +276,12 @@ export function useVoiceChat() { } try { - // Callback invocato per ogni chunk audio resampled a 24kHz + // Callback invocato per ogni chunk audio PCM16 a 24kHz const onChunk = (base64Chunk: string) => { websocketRef.current?.sendAudio(base64Chunk); }; - const started = await audioRecorderRef.current.startRecording(true, vadCallbacks, onChunk); + const started = await audioRecorderRef.current.startRecording(onChunk); if (!started) { setError('Impossibile avviare la registrazione'); return false; @@ -315,8 +289,6 @@ export function useVoiceChat() { setState('recording'); setError(null); - setIsSpeechActive(false); - setAudioLevel(-160); // Aggiorna la durata della registrazione ogni 100ms recordingIntervalRef.current = setInterval(() => { @@ -350,13 +322,11 @@ export function useVoiceChat() { try { await audioRecorderRef.current.stopRecording(); - // I chunks sono gia' stati inviati in streaming, committa il buffer websocketRef.current.sendAudioCommit(); console.log('Buffer committato (chunks inviati in streaming)'); setState('processing'); setRecordingDuration(0); - setIsSpeechActive(false); return true; } catch (err) { @@ -381,7 +351,6 @@ export function useVoiceChat() { } setRecordingDuration(0); - setIsSpeechActive(false); setState('ready'); }, []); @@ -497,10 +466,6 @@ export function useVoiceChat() { canRecord, canStop, - // VAD stati - audioLevel, - isSpeechActive, - // Azioni initialize, connect, diff --git a/src/utils/audioUtils.ts b/src/utils/audioUtils.ts index 29252bc..eab6fe8 100644 --- a/src/utils/audioUtils.ts +++ b/src/utils/audioUtils.ts @@ -81,18 +81,6 @@ export const AUDIO_CONFIG = { MAX_RECORDING_TIME: 300000, // 5 minuti per sessioni conversazionali }; -// Configurazioni VAD (Voice Activity Detection) - implementata via analisi energia RMS -export const VAD_CONFIG = { - VOICE_ACTIVITY_THRESHOLD: 0.5, // Sensibilita' VAD (0.0-1.0) - SILENCE_DURATION_MS: 1200, - MIN_RECORDING_DURATION_MS: 500, -}; - -// Configurazione per normalizzazione audio level -export const AUDIO_LEVEL_CONFIG = { - MIN_DB: -80, - MAX_DB: -10, -}; /** * Converte un array di campioni Int16 (number[]) in Uint8Array little-endian PCM16. @@ -107,23 +95,6 @@ function int16ArrayToBytes(samples: number[]): Uint8Array { return buffer; } -/** - * Calcola il livello RMS in dB da un array di campioni Int16. - * Usato per metering e per la VAD basata su energia. - */ -function computeRmsDb(samples: number[]): number { - if (samples.length === 0) return AUDIO_LEVEL_CONFIG.MIN_DB; - let sumSquares = 0; - for (let i = 0; i < samples.length; i++) { - const normalized = samples[i] / 32768; - sumSquares += normalized * normalized; - } - const rms = Math.sqrt(sumSquares / samples.length); - if (rms === 0) return AUDIO_LEVEL_CONFIG.MIN_DB; - const db = 20 * Math.log10(rms); - return Math.max(AUDIO_LEVEL_CONFIG.MIN_DB, Math.min(AUDIO_LEVEL_CONFIG.MAX_DB, db)); -} - /** * Crea un header WAV per dati audio PCM16. * Necessario perche' expo-av richiede un formato file per la riproduzione. @@ -175,21 +146,9 @@ export function wrapPcm16InWav(pcm16Data: Uint8Array, sampleRate: number = 24000 return wav; } -/** - * Callback per eventi VAD - */ -export interface VADCallbacks { - onSpeechStart?: () => void; - onSpeechEnd?: () => void; - onSilenceDetected?: () => void; - onAutoStop?: () => void; - onMeteringUpdate?: (level: number) => void; -} - /** * Classe per gestire la registrazione audio con @picovoice/react-native-voice-processor. - * Fornisce streaming di chunks PCM16 base64 a 24kHz direttamente (nessun resampling). - * VAD implementata tramite analisi energia RMS dei frames. + * Fornisce streaming di chunks PCM16 base64 a 24kHz direttamente. */ export class AudioRecorder { private isRecording: boolean = false; @@ -197,10 +156,6 @@ export class AudioRecorder { private onChunkCallback: ((base64Chunk: string) => void) | null = null; private frameListener: ((frame: number[]) => void) | null = null; private errorListener: ((error: any) => void) | null = null; - private vadEnabled: boolean = false; - private vadCallbacks: VADCallbacks = {}; - private isSpeechDetected: boolean = false; - private silenceStartTime: number | null = null; private voiceProcessor: VoiceProcessor = VoiceProcessor.instance; /** @@ -208,16 +163,10 @@ export class AudioRecorder { * VoiceProcessor registra direttamente a 24kHz, ogni frame viene convertito in PCM16 base64. */ async startRecording( - enableVAD: boolean = false, - vadCallbacks?: VADCallbacks, onChunk?: (base64Chunk: string) => void ): Promise { try { this.onChunkCallback = onChunk || null; - this.vadEnabled = enableVAD; - this.vadCallbacks = vadCallbacks || {}; - this.isSpeechDetected = false; - this.silenceStartTime = null; // Verifica permessi microfono if (!(await this.voiceProcessor.hasRecordAudioPermission())) { @@ -236,15 +185,6 @@ export class AudioRecorder { // Encode in base64 e invia const base64Chunk = encodeBase64(pcm16Bytes); this.onChunkCallback?.(base64Chunk); - - // Calcola livello audio per metering - const rmsDb = computeRmsDb(frame); - this.vadCallbacks.onMeteringUpdate?.(rmsDb); - - // VAD basata su energia RMS - if (this.vadEnabled) { - this.processVAD(rmsDb); - } } catch (error) { console.error('Errore processamento frame audio:', error); } @@ -264,7 +204,7 @@ export class AudioRecorder { this.isRecording = true; this.recordingStartTime = Date.now(); - console.log('Registrazione VoiceProcessor avviata', enableVAD ? '(VAD attivo)' : '', '- streaming PCM16 a 24kHz'); + console.log('Registrazione VoiceProcessor avviata - streaming PCM16 a 24kHz'); return true; } catch (error) { @@ -274,47 +214,8 @@ export class AudioRecorder { } } - /** - * Processa VAD basata su energia RMS. - * Soglia: se il livello dB supera il threshold -> voce rilevata, altrimenti silenzio. - */ - private processVAD(rmsDb: number): void { - if (!this.isRecording) return; - - const recordingDuration = this.getRecordingDuration(); - if (recordingDuration < VAD_CONFIG.MIN_RECORDING_DURATION_MS) return; - - // Mappa la soglia VAD (0.0-1.0) a un range dB - const dbThreshold = AUDIO_LEVEL_CONFIG.MIN_DB + - VAD_CONFIG.VOICE_ACTIVITY_THRESHOLD * (AUDIO_LEVEL_CONFIG.MAX_DB - AUDIO_LEVEL_CONFIG.MIN_DB); - - const isVoice = rmsDb > dbThreshold; - - if (isVoice) { - if (!this.isSpeechDetected) { - this.isSpeechDetected = true; - this.vadCallbacks.onSpeechStart?.(); - } - this.silenceStartTime = null; - } else { - if (this.isSpeechDetected) { - if (this.silenceStartTime === null) { - this.silenceStartTime = Date.now(); - this.vadCallbacks.onSilenceDetected?.(); - } else { - const silenceDuration = Date.now() - this.silenceStartTime; - if (silenceDuration >= VAD_CONFIG.SILENCE_DURATION_MS) { - this.vadCallbacks.onAutoStop?.(); - this.vadCallbacks.onSpeechEnd?.(); - } - } - } - } - } - /** * Ferma la registrazione. I chunks sono gia' stati inviati in streaming. - * Restituisce null (non serve piu' il blob completo). */ async stopRecording(): Promise { if (!this.isRecording) { @@ -368,9 +269,6 @@ export class AudioRecorder { this.isRecording = false; this.recordingStartTime = 0; this.onChunkCallback = null; - this.silenceStartTime = null; - this.vadEnabled = false; - this.isSpeechDetected = false; } } From 215d5c3d09897d8f79cc47a532fc71fd30b580d2 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 2 Feb 2026 16:40:30 +0100 Subject: [PATCH 07/29] refactor: rename botservice imports to textBotService for consistency --- src/hooks/useVoiceChat.ts | 2 +- src/navigation/screens/BotChat.tsx | 2 +- src/navigation/screens/Home.tsx | 2 +- src/services/taskService.ts | 1 - .../{botservice.ts => textBotService.ts} | 547 +----------------- src/services/voiceBotService.ts | 508 ++++++++++++++++ test/test_botservice.ts | 2 +- 7 files changed, 532 insertions(+), 532 deletions(-) rename src/services/{botservice.ts => textBotService.ts} (51%) create mode 100644 src/services/voiceBotService.ts diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 8dc711b..fefecb8 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -1,5 +1,5 @@ import { useState, useRef, useCallback, useEffect } from 'react'; -import { VoiceBotWebSocket, VoiceChatCallbacks, VoiceServerPhase } from '../services/botservice'; +import { VoiceBotWebSocket, VoiceChatCallbacks, VoiceServerPhase } from '../services/voiceBotService'; import { AudioRecorder, AudioPlayer, checkAudioPermissions } from '../utils/audioUtils'; /** diff --git a/src/navigation/screens/BotChat.tsx b/src/navigation/screens/BotChat.tsx index 7d66a33..acbdf62 100644 --- a/src/navigation/screens/BotChat.tsx +++ b/src/navigation/screens/BotChat.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect } from 'react'; import { View, KeyboardAvoidingView, Platform, SafeAreaView, Alert, Keyboard, Dimensions } from 'react-native'; -import { sendMessageToBot, createNewChat, formatMessage, clearChatHistory } from '../../services/botservice'; +import { sendMessageToBot, createNewChat, formatMessage, clearChatHistory } from '../../services/textBotService'; import { getChatWithMessages, ChatMessage } from '../../services/chatHistoryService'; import { ChatHeader, diff --git a/src/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index cb7e415..1747079 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -20,7 +20,7 @@ import { Ionicons } from "@expo/vector-icons"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { ChatList, Message } from "../../components/BotChat"; import { ToolWidget } from "../../components/BotChat/types"; -import { sendMessageToBot, formatMessage, StreamingCallback, createNewChat } from "../../services/botservice"; +import { sendMessageToBot, formatMessage, StreamingCallback, createNewChat } from "../../services/textBotService"; import { getChatWithMessages, ChatMessage } from "../../services/chatHistoryService"; import { STORAGE_KEYS } from "../../constants/authConstants"; import { TaskCacheService } from '../../services/TaskCacheService'; diff --git a/src/services/taskService.ts b/src/services/taskService.ts index 0983e11..6665fbb 100644 --- a/src/services/taskService.ts +++ b/src/services/taskService.ts @@ -1,7 +1,6 @@ import axios from "./axiosInterceptor"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { STORAGE_KEYS } from "../constants/authConstants"; -// eslint-disable-next-line import/no-named-as-default import TaskCacheService from './TaskCacheService'; import SyncManager from './SyncManager'; import { emitTaskAdded, emitTaskUpdated, emitTaskDeleted, emitTasksSynced } from '../utils/eventEmitter'; diff --git a/src/services/botservice.ts b/src/services/textBotService.ts similarity index 51% rename from src/services/botservice.ts rename to src/services/textBotService.ts index e6b0f1e..07e417a 100644 --- a/src/services/botservice.ts +++ b/src/services/textBotService.ts @@ -34,7 +34,7 @@ export async function sendMessageToBot( if (!token) { return "Mi dispiace, sembra che tu non sia autenticato. Effettua il login per continuare."; } - + // Costruisci il payload per la richiesta const requestPayload: any = { quest: userMessage, @@ -103,7 +103,7 @@ export async function sendMessageToBot( if (parsed.type === 'chat_info') { receivedChatId = parsed.chat_id; isNewChat = parsed.is_new; - console.log(`[BOTSERVICE] Chat info ricevuto: chat_id=${receivedChatId}, is_new=${isNewChat}`); + console.log(`[TEXTBOTSERVICE] Chat info ricevuto: chat_id=${receivedChatId}, is_new=${isNewChat}`); // Notifica UI del chat_id ricevuto if (onStreamChunk) { @@ -190,7 +190,7 @@ export async function sendMessageToBot( } catch (e: any) { widget.status = 'error'; widget.errorMessage = 'Errore parsing output tool'; - console.error('[BOTSERVICE] Error parsing tool output:', e); + console.error('[TEXTBOTSERVICE] Error parsing tool output:', e); } // IMPORTANTE: Aggiorna il widget nella posizione ORIGINALE, non creare un duplicato @@ -201,7 +201,7 @@ export async function sendMessageToBot( onStreamChunk('', false, Array.from(toolWidgetsMap.values())); } } else { - console.warn('[BOTSERVICE] Widget not found for index:', parsed.item_index); + console.warn('[TEXTBOTSERVICE] Widget not found for index:', parsed.item_index); } } @@ -263,7 +263,7 @@ export async function sendMessageToBot( chat_id: receivedChatId, is_new: isNewChat, }; - + } catch (error: any) { console.error("❌ Errore nella comunicazione con il bot:", error); @@ -315,7 +315,7 @@ export async function clearChatHistory(): Promise { console.log("βœ… Cronologia chat eliminata dal server"); return true; - + } catch (error: any) { console.error("❌ Errore nell'eliminazione della cronologia chat:", error); return false; @@ -352,14 +352,14 @@ export function validateMessage(message: string): boolean { if (!message || typeof message !== 'string') { return false; } - + const trimmedMessage = message.trim(); - + // Controllo lunghezza minima e massima if (trimmedMessage.length === 0 || trimmedMessage.length > 5000) { return false; } - + return true; } @@ -372,52 +372,52 @@ export function formatMessage(message: string): string { if (!message || typeof message !== 'string') { return ""; } - + let formattedMessage = message.trim(); - + // Converte alcuni pattern comuni in Markdown // Titoli con emoji task formattedMessage = formattedMessage.replace( - /πŸ“… TASK PER LA DATA (.+?):/g, + /πŸ“… TASK PER LA DATA (.+?):/g, '## πŸ“… Task per la data $1\n\n' ); - + // Totale task trovati formattedMessage = formattedMessage.replace( /πŸ“Š Totale task trovati: (\d+)/g, '\n---\n**πŸ“Š Totale task trovati:** `$1`' ); - + // Pattern per evidenziare i numeri di task formattedMessage = formattedMessage.replace( /(\d+) task/g, '**$1** task' ); - + // Pattern per evidenziare le date formattedMessage = formattedMessage.replace( /(\d{4}-\d{2}-\d{2})/g, '`$1`' ); - + // Pattern per evidenziare gli orari formattedMessage = formattedMessage.replace( /(\d{2}:\d{2})/g, '`$1`' ); - + // Converti status in badge formattedMessage = formattedMessage.replace( /"status":\s*"([^"]+)"/g, '"status": **$1**' ); - + // Converti category_name in evidenziato formattedMessage = formattedMessage.replace( /"category_name":\s*"([^"]+)"/g, '"category_name": *$1*' ); - + return formattedMessage; } @@ -430,7 +430,7 @@ export function isStructuredResponse(response: string): boolean { if (!response || typeof response !== 'string') { return false; } - + try { const parsed = JSON.parse(response); return parsed && typeof parsed === 'object' && parsed.mode === 'view'; @@ -451,510 +451,3 @@ export function extractStructuredData(response: string): any { return null; } } - -// ============= VOICE CHAT WEBSOCKET (OpenAI Realtime API) ============= - -/** - * Tipi per i messaggi client -> server - */ -export interface VoiceAuthMessage { - type: 'auth'; - token: string; -} - -export interface VoiceAudioMessage { - type: 'audio'; - data: string; // base64 PCM16 -} - -export interface VoiceAudioCommitMessage { - type: 'audio_commit'; -} - -export interface VoiceTextMessage { - type: 'text'; - content: string; -} - -export interface VoiceInterruptMessage { - type: 'interrupt'; -} - -export type VoiceClientMessage = - | VoiceAuthMessage - | VoiceAudioMessage - | VoiceAudioCommitMessage - | VoiceTextMessage - | VoiceInterruptMessage; - -/** - * Tipi per i messaggi server -> client - */ -export type VoiceServerPhase = - | 'authenticated' - | 'ready' - | 'interrupted' - | 'audio_end' - | 'agent_start' - | 'agent_end'; - -export interface VoiceStatusResponse { - type: 'status'; - phase: VoiceServerPhase; - message?: string; -} - -export interface VoiceAudioResponse { - type: 'audio'; - data: string; // base64 PCM16 - chunk_index: number; -} - -export interface VoiceTranscriptResponse { - type: 'transcript'; - role: 'user' | 'assistant'; - content: string; -} - -export interface VoiceToolStartResponse { - type: 'tool_start'; - tool_name: string; - arguments: string; -} - -export interface VoiceToolEndResponse { - type: 'tool_end'; - tool_name: string; - output: string; -} - -export interface VoiceErrorResponse { - type: 'error'; - message: string; -} - -export interface VoiceDoneResponse { - type: 'done'; -} - -export type VoiceServerMessage = - | VoiceStatusResponse - | VoiceAudioResponse - | VoiceTranscriptResponse - | VoiceToolStartResponse - | VoiceToolEndResponse - | VoiceErrorResponse - | VoiceDoneResponse; - -/** - * Stati di autenticazione/connessione WebSocket - */ -export enum WebSocketAuthState { - DISCONNECTED = 'disconnected', - CONNECTING = 'connecting', - AUTHENTICATING = 'authenticating', - AUTHENTICATED = 'authenticated', - READY = 'ready', - FAILED = 'failed' -} - -/** - * Callback per gestire i diversi tipi di risposta dal WebSocket vocale - */ -export interface VoiceChatCallbacks { - onStatus?: (phase: VoiceServerPhase, message: string) => void; - onAudioChunk?: (audioData: string, chunkIndex: number) => void; - onTranscript?: (role: 'user' | 'assistant', content: string) => void; - onToolStart?: (toolName: string, args: string) => void; - onToolEnd?: (toolName: string, output: string) => void; - onError?: (error: string) => void; - onConnectionOpen?: () => void; - onConnectionClose?: () => void; - onAuthenticationSuccess?: (message: string) => void; - onAuthenticationFailed?: (error: string) => void; - onReady?: () => void; - onDone?: () => void; -} - -const MAX_AUDIO_CHUNK_BYTES = 2_500_000; - -/** - * Classe per gestire la connessione WebSocket per la chat vocale - * Compatibile con l'OpenAI Realtime API tramite il backend - */ -export class VoiceBotWebSocket { - private ws: WebSocket | null = null; - private callbacks: VoiceChatCallbacks; - private baseUrl: string = 'wss://taskly-production.up.railway.app'; - private reconnectAttempts: number = 0; - private maxReconnectAttempts: number = 3; - private reconnectDelay: number = 1000; - private authState: WebSocketAuthState = WebSocketAuthState.DISCONNECTED; - private messageQueue: VoiceClientMessage[] = []; - private authTimeout: NodeJS.Timeout | null = null; - private readonly AUTH_TIMEOUT_MS = 15000; // 15s timeout (setup MCP + RealtimeAgent) - - constructor(callbacks: VoiceChatCallbacks) { - this.callbacks = callbacks; - } - - /** - * Connette al WebSocket per la chat vocale - */ - async connect(): Promise { - try { - const token = await getValidToken(); - if (!token) { - this.authState = WebSocketAuthState.FAILED; - this.callbacks.onError?.('Token di autenticazione non disponibile'); - return false; - } - - this.authState = WebSocketAuthState.CONNECTING; - const wsUrl = `${this.baseUrl}/chat/voice-bot-websocket`; - - return new Promise((resolve, reject) => { - this.ws = new WebSocket(wsUrl); - - const connectionTimeout = setTimeout(() => { - this.authState = WebSocketAuthState.FAILED; - this.callbacks.onError?.('Timeout connessione WebSocket'); - reject(new Error('Timeout connessione WebSocket')); - }, 10000); - - this.ws.onopen = () => { - clearTimeout(connectionTimeout); - this.reconnectAttempts = 0; - this.startAuthentication(token); - this.callbacks.onConnectionOpen?.(); - resolve(true); - }; - - this.ws.onmessage = (event) => { - try { - const response = JSON.parse(event.data); - this.handleResponse(response); - } catch (error) { - console.error('Errore parsing risposta WebSocket:', error); - this.callbacks.onError?.('Errore nel formato della risposta del server'); - } - }; - - this.ws.onerror = (error) => { - clearTimeout(connectionTimeout); - console.error('Errore WebSocket vocale:', error); - this.authState = WebSocketAuthState.FAILED; - this.clearAuthTimeout(); - this.callbacks.onError?.('Errore di connessione WebSocket'); - reject(new Error('Errore di connessione WebSocket')); - }; - - this.ws.onclose = (event) => { - clearTimeout(connectionTimeout); - this.authState = WebSocketAuthState.DISCONNECTED; - this.clearAuthTimeout(); - this.messageQueue = []; - this.callbacks.onConnectionClose?.(); - - if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { - this.attemptReconnect(); - } - }; - }); - } catch (error) { - console.error('Errore connessione WebSocket vocale:', error); - this.authState = WebSocketAuthState.FAILED; - this.callbacks.onError?.('Impossibile connettersi al servizio vocale'); - return false; - } - } - - /** - * Avvia il processo di autenticazione - */ - private startAuthentication(token: string): void { - if (!this.isConnected()) return; - - this.authState = WebSocketAuthState.AUTHENTICATING; - - this.authTimeout = setTimeout(() => { - this.authState = WebSocketAuthState.FAILED; - this.callbacks.onAuthenticationFailed?.('Timeout autenticazione - il server non ha risposto'); - this.disconnect(); - }, this.AUTH_TIMEOUT_MS); - - const authMessage: VoiceAuthMessage = { - type: 'auth', - token: token.startsWith('Bearer ') ? token : `Bearer ${token}` - }; - - this.ws!.send(JSON.stringify(authMessage)); - } - - private clearAuthTimeout(): void { - if (this.authTimeout) { - clearTimeout(this.authTimeout); - this.authTimeout = null; - } - } - - /** - * Gestisce le risposte ricevute dal WebSocket - */ - private handleResponse(response: VoiceServerMessage): void { - if (!this.validateResponse(response)) { - console.warn('Messaggio WebSocket non valido ricevuto:', response); - return; - } - - switch (response.type) { - case 'status': - this.handleStatusResponse(response as VoiceStatusResponse); - break; - - case 'audio': - this.handleAudioResponse(response as VoiceAudioResponse); - break; - - case 'transcript': - this.handleTranscriptResponse(response as VoiceTranscriptResponse); - break; - - case 'tool_start': - this.callbacks.onToolStart?.( - (response as VoiceToolStartResponse).tool_name, - (response as VoiceToolStartResponse).arguments - ); - break; - - case 'tool_end': - this.callbacks.onToolEnd?.( - (response as VoiceToolEndResponse).tool_name, - (response as VoiceToolEndResponse).output - ); - break; - - case 'error': - this.handleErrorResponse(response as VoiceErrorResponse); - break; - - case 'done': - this.callbacks.onDone?.(); - break; - } - } - - /** - * Gestisce risposta di stato - */ - private handleStatusResponse(response: VoiceStatusResponse): void { - const phase = response.phase; - const message = response.message || ''; - - switch (phase) { - case 'authenticated': - console.log('Autenticazione WebSocket riuscita:', message); - this.authState = WebSocketAuthState.AUTHENTICATED; - this.callbacks.onAuthenticationSuccess?.(message); - this.callbacks.onStatus?.(phase, message); - // Non processare la coda qui - aspettare 'ready' - break; - - case 'ready': - console.log('Sessione vocale pronta'); - this.authState = WebSocketAuthState.READY; - this.clearAuthTimeout(); - this.processQueuedMessages(); - this.callbacks.onReady?.(); - this.callbacks.onStatus?.(phase, message); - break; - - case 'interrupted': - case 'audio_end': - case 'agent_start': - case 'agent_end': - this.callbacks.onStatus?.(phase, message); - break; - - default: - console.warn('Fase WebSocket sconosciuta:', phase); - } - } - - /** - * Gestisce risposta audio PCM16 - */ - private handleAudioResponse(response: VoiceAudioResponse): void { - if (this.authState === WebSocketAuthState.DISCONNECTED) return; - - if (response.data) { - this.callbacks.onAudioChunk?.(response.data, response.chunk_index); - } - } - - /** - * Gestisce risposta di trascrizione - */ - private handleTranscriptResponse(response: VoiceTranscriptResponse): void { - this.callbacks.onTranscript?.(response.role, response.content); - } - - /** - * Gestisce risposta di errore - */ - private handleErrorResponse(response: VoiceErrorResponse): void { - if (!response.message) return; - - if (this.authState === WebSocketAuthState.AUTHENTICATING) { - this.authState = WebSocketAuthState.FAILED; - this.clearAuthTimeout(); - this.callbacks.onAuthenticationFailed?.(response.message); - } else { - this.callbacks.onError?.(response.message); - } - } - - /** - * Invia un chunk audio PCM16 base64 al server - */ - sendAudio(base64Pcm16Data: string): void { - console.log(`πŸ” DEBUG sendAudio: base64 length=${base64Pcm16Data.length}, approx bytes=${Math.floor(base64Pcm16Data.length * 0.75)}`); - console.log(`πŸ” DEBUG sendAudio: primi 50 chars base64="${base64Pcm16Data.substring(0, 50)}"`); - console.log(`πŸ” DEBUG sendAudio: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); - const msg: VoiceAudioMessage = { type: 'audio', data: base64Pcm16Data }; - const jsonMsg = JSON.stringify(msg); - console.log(`πŸ” DEBUG sendAudio: JSON message size=${jsonMsg.length} bytes`); - this.sendOrQueue(msg); - } - - /** - * Committa il buffer audio (opzionale - il server ha semantic VAD) - */ - sendAudioCommit(): void { - console.log(`πŸ” DEBUG sendAudioCommit: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); - const msg: VoiceAudioCommitMessage = { type: 'audio_commit' }; - this.sendOrQueue(msg); - console.log('πŸ” DEBUG sendAudioCommit: messaggio inviato'); - } - - /** - * Invia un messaggio di testo all'assistente - */ - sendText(content: string): void { - const msg: VoiceTextMessage = { type: 'text', content }; - this.sendOrQueue(msg); - } - - /** - * Interrompe la risposta corrente dell'assistente - */ - sendInterrupt(): void { - if (this.isConnected()) { - this.ws!.send(JSON.stringify({ type: 'interrupt' } as VoiceInterruptMessage)); - } - } - - /** - * Invia un messaggio o lo mette in coda se non ancora pronto - */ - private sendOrQueue(message: VoiceClientMessage): void { - if (!this.isConnected()) { - console.log(`πŸ” DEBUG sendOrQueue: BLOCCATO - non connesso (type=${message.type})`); - this.callbacks.onError?.('Connessione WebSocket non disponibile'); - return; - } - - if (!this.isReady()) { - console.log(`πŸ” DEBUG sendOrQueue: IN CODA - non pronto (type=${message.type}, authState=${this.authState})`); - this.messageQueue.push(message); - return; - } - - const json = JSON.stringify(message); - console.log(`πŸ” DEBUG sendOrQueue: INVIATO type=${message.type}, size=${json.length} bytes`); - this.ws!.send(json); - } - - /** - * Processa i messaggi in coda dopo che la sessione e' pronta - */ - private processQueuedMessages(): void { - if (this.messageQueue.length === 0) return; - - while (this.messageQueue.length > 0) { - const message = this.messageQueue.shift(); - if (message && this.isConnected()) { - this.ws!.send(JSON.stringify(message)); - } - } - } - - /** - * Valida una risposta WebSocket - */ - private validateResponse(response: any): response is VoiceServerMessage { - if (!response || typeof response !== 'object') return false; - - const validTypes = ['status', 'audio', 'transcript', 'tool_start', 'tool_end', 'error', 'done']; - if (!validTypes.includes(response.type)) return false; - - // Verifica lunghezza messaggi - if (response.message && typeof response.message === 'string' && response.message.length > 5000) { - console.warn('Messaggio troppo lungo ricevuto dal server'); - return false; - } - - // Verifica chunk audio - if (response.type === 'audio' && response.data) { - const approxBytes = Math.floor(response.data.length * 0.75); - if (approxBytes > MAX_AUDIO_CHUNK_BYTES) { - console.warn(`Chunk audio molto grande (~${approxBytes} bytes)`); - } - } - - return true; - } - - isConnected(): boolean { - return this.ws !== null && this.ws.readyState === WebSocket.OPEN; - } - - isAuthenticated(): boolean { - return this.authState === WebSocketAuthState.AUTHENTICATED || - this.authState === WebSocketAuthState.READY; - } - - isReady(): boolean { - return this.authState === WebSocketAuthState.READY; - } - - getAuthState(): WebSocketAuthState { - return this.authState; - } - - private attemptReconnect(): void { - this.reconnectAttempts++; - const delay = this.reconnectDelay * this.reconnectAttempts; - - setTimeout(() => { - this.connect(); - }, delay); - } - - disconnect(): void { - this.clearAuthTimeout(); - this.messageQueue = []; - this.authState = WebSocketAuthState.DISCONNECTED; - - if (this.ws) { - this.ws.close(1000, 'Disconnessione volontaria'); - this.ws = null; - } - } - - destroy(): void { - this.disconnect(); - this.callbacks = {}; - this.reconnectAttempts = 0; - } -} diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts new file mode 100644 index 0000000..a92dbb8 --- /dev/null +++ b/src/services/voiceBotService.ts @@ -0,0 +1,508 @@ +import { getValidToken } from "./authService"; + +// ============= VOICE CHAT WEBSOCKET (OpenAI Realtime API) ============= + +/** + * Tipi per i messaggi client -> server + */ +export interface VoiceAuthMessage { + type: 'auth'; + token: string; +} + +export interface VoiceAudioMessage { + type: 'audio'; + data: string; // base64 PCM16 +} + +export interface VoiceAudioCommitMessage { + type: 'audio_commit'; +} + +export interface VoiceTextMessage { + type: 'text'; + content: string; +} + +export interface VoiceInterruptMessage { + type: 'interrupt'; +} + +export type VoiceClientMessage = + | VoiceAuthMessage + | VoiceAudioMessage + | VoiceAudioCommitMessage + | VoiceTextMessage + | VoiceInterruptMessage; + +/** + * Tipi per i messaggi server -> client + */ +export type VoiceServerPhase = + | 'authenticated' + | 'ready' + | 'interrupted' + | 'audio_end' + | 'agent_start' + | 'agent_end'; + +export interface VoiceStatusResponse { + type: 'status'; + phase: VoiceServerPhase; + message?: string; +} + +export interface VoiceAudioResponse { + type: 'audio'; + data: string; // base64 PCM16 + chunk_index: number; +} + +export interface VoiceTranscriptResponse { + type: 'transcript'; + role: 'user' | 'assistant'; + content: string; +} + +export interface VoiceToolStartResponse { + type: 'tool_start'; + tool_name: string; + arguments: string; +} + +export interface VoiceToolEndResponse { + type: 'tool_end'; + tool_name: string; + output: string; +} + +export interface VoiceErrorResponse { + type: 'error'; + message: string; +} + +export interface VoiceDoneResponse { + type: 'done'; +} + +export type VoiceServerMessage = + | VoiceStatusResponse + | VoiceAudioResponse + | VoiceTranscriptResponse + | VoiceToolStartResponse + | VoiceToolEndResponse + | VoiceErrorResponse + | VoiceDoneResponse; + +/** + * Stati di autenticazione/connessione WebSocket + */ +export enum WebSocketAuthState { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + AUTHENTICATING = 'authenticating', + AUTHENTICATED = 'authenticated', + READY = 'ready', + FAILED = 'failed' +} + +/** + * Callback per gestire i diversi tipi di risposta dal WebSocket vocale + */ +export interface VoiceChatCallbacks { + onStatus?: (phase: VoiceServerPhase, message: string) => void; + onAudioChunk?: (audioData: string, chunkIndex: number) => void; + onTranscript?: (role: 'user' | 'assistant', content: string) => void; + onToolStart?: (toolName: string, args: string) => void; + onToolEnd?: (toolName: string, output: string) => void; + onError?: (error: string) => void; + onConnectionOpen?: () => void; + onConnectionClose?: () => void; + onAuthenticationSuccess?: (message: string) => void; + onAuthenticationFailed?: (error: string) => void; + onReady?: () => void; + onDone?: () => void; +} + +const MAX_AUDIO_CHUNK_BYTES = 2_500_000; + +/** + * Classe per gestire la connessione WebSocket per la chat vocale + * Compatibile con l'OpenAI Realtime API tramite il backend + */ +export class VoiceBotWebSocket { + private ws: WebSocket | null = null; + private callbacks: VoiceChatCallbacks; + private baseUrl: string = 'wss://taskly-production.up.railway.app'; + private reconnectAttempts: number = 0; + private maxReconnectAttempts: number = 3; + private reconnectDelay: number = 1000; + private authState: WebSocketAuthState = WebSocketAuthState.DISCONNECTED; + private messageQueue: VoiceClientMessage[] = []; + private authTimeout: NodeJS.Timeout | null = null; + private readonly AUTH_TIMEOUT_MS = 15000; // 15s timeout (setup MCP + RealtimeAgent) + + constructor(callbacks: VoiceChatCallbacks) { + this.callbacks = callbacks; + } + + /** + * Connette al WebSocket per la chat vocale + */ + async connect(): Promise { + try { + const token = await getValidToken(); + if (!token) { + this.authState = WebSocketAuthState.FAILED; + this.callbacks.onError?.('Token di autenticazione non disponibile'); + return false; + } + + this.authState = WebSocketAuthState.CONNECTING; + const wsUrl = `${this.baseUrl}/chat/voice-bot-websocket`; + + return new Promise((resolve, reject) => { + this.ws = new WebSocket(wsUrl); + + const connectionTimeout = setTimeout(() => { + this.authState = WebSocketAuthState.FAILED; + this.callbacks.onError?.('Timeout connessione WebSocket'); + reject(new Error('Timeout connessione WebSocket')); + }, 10000); + + this.ws.onopen = () => { + clearTimeout(connectionTimeout); + this.reconnectAttempts = 0; + this.startAuthentication(token); + this.callbacks.onConnectionOpen?.(); + resolve(true); + }; + + this.ws.onmessage = (event) => { + try { + const response = JSON.parse(event.data); + this.handleResponse(response); + } catch (error) { + console.error('Errore parsing risposta WebSocket:', error); + this.callbacks.onError?.('Errore nel formato della risposta del server'); + } + }; + + this.ws.onerror = (error) => { + clearTimeout(connectionTimeout); + console.error('Errore WebSocket vocale:', error); + this.authState = WebSocketAuthState.FAILED; + this.clearAuthTimeout(); + this.callbacks.onError?.('Errore di connessione WebSocket'); + reject(new Error('Errore di connessione WebSocket')); + }; + + this.ws.onclose = (event) => { + clearTimeout(connectionTimeout); + this.authState = WebSocketAuthState.DISCONNECTED; + this.clearAuthTimeout(); + this.messageQueue = []; + this.callbacks.onConnectionClose?.(); + + if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { + this.attemptReconnect(); + } + }; + }); + } catch (error) { + console.error('Errore connessione WebSocket vocale:', error); + this.authState = WebSocketAuthState.FAILED; + this.callbacks.onError?.('Impossibile connettersi al servizio vocale'); + return false; + } + } + + /** + * Avvia il processo di autenticazione + */ + private startAuthentication(token: string): void { + if (!this.isConnected()) return; + + this.authState = WebSocketAuthState.AUTHENTICATING; + + this.authTimeout = setTimeout(() => { + this.authState = WebSocketAuthState.FAILED; + this.callbacks.onAuthenticationFailed?.('Timeout autenticazione - il server non ha risposto'); + this.disconnect(); + }, this.AUTH_TIMEOUT_MS); + + const authMessage: VoiceAuthMessage = { + type: 'auth', + token: token.startsWith('Bearer ') ? token : `Bearer ${token}` + }; + + this.ws!.send(JSON.stringify(authMessage)); + } + + private clearAuthTimeout(): void { + if (this.authTimeout) { + clearTimeout(this.authTimeout); + this.authTimeout = null; + } + } + + /** + * Gestisce le risposte ricevute dal WebSocket + */ + private handleResponse(response: VoiceServerMessage): void { + if (!this.validateResponse(response)) { + console.warn('Messaggio WebSocket non valido ricevuto:', response); + return; + } + + switch (response.type) { + case 'status': + this.handleStatusResponse(response as VoiceStatusResponse); + break; + + case 'audio': + this.handleAudioResponse(response as VoiceAudioResponse); + break; + + case 'transcript': + this.handleTranscriptResponse(response as VoiceTranscriptResponse); + break; + + case 'tool_start': + this.callbacks.onToolStart?.( + (response as VoiceToolStartResponse).tool_name, + (response as VoiceToolStartResponse).arguments + ); + break; + + case 'tool_end': + this.callbacks.onToolEnd?.( + (response as VoiceToolEndResponse).tool_name, + (response as VoiceToolEndResponse).output + ); + break; + + case 'error': + this.handleErrorResponse(response as VoiceErrorResponse); + break; + + case 'done': + this.callbacks.onDone?.(); + break; + } + } + + /** + * Gestisce risposta di stato + */ + private handleStatusResponse(response: VoiceStatusResponse): void { + const phase = response.phase; + const message = response.message || ''; + + switch (phase) { + case 'authenticated': + console.log('Autenticazione WebSocket riuscita:', message); + this.authState = WebSocketAuthState.AUTHENTICATED; + this.callbacks.onAuthenticationSuccess?.(message); + this.callbacks.onStatus?.(phase, message); + // Non processare la coda qui - aspettare 'ready' + break; + + case 'ready': + console.log('Sessione vocale pronta'); + this.authState = WebSocketAuthState.READY; + this.clearAuthTimeout(); + this.processQueuedMessages(); + this.callbacks.onReady?.(); + this.callbacks.onStatus?.(phase, message); + break; + + case 'interrupted': + case 'audio_end': + case 'agent_start': + case 'agent_end': + this.callbacks.onStatus?.(phase, message); + break; + + default: + console.warn('Fase WebSocket sconosciuta:', phase); + } + } + + /** + * Gestisce risposta audio PCM16 + */ + private handleAudioResponse(response: VoiceAudioResponse): void { + if (this.authState === WebSocketAuthState.DISCONNECTED) return; + + if (response.data) { + this.callbacks.onAudioChunk?.(response.data, response.chunk_index); + } + } + + /** + * Gestisce risposta di trascrizione + */ + private handleTranscriptResponse(response: VoiceTranscriptResponse): void { + this.callbacks.onTranscript?.(response.role, response.content); + } + + /** + * Gestisce risposta di errore + */ + private handleErrorResponse(response: VoiceErrorResponse): void { + if (!response.message) return; + + if (this.authState === WebSocketAuthState.AUTHENTICATING) { + this.authState = WebSocketAuthState.FAILED; + this.clearAuthTimeout(); + this.callbacks.onAuthenticationFailed?.(response.message); + } else { + this.callbacks.onError?.(response.message); + } + } + + /** + * Invia un chunk audio PCM16 base64 al server + */ + sendAudio(base64Pcm16Data: string): void { + console.log(`πŸ” DEBUG sendAudio: base64 length=${base64Pcm16Data.length}, approx bytes=${Math.floor(base64Pcm16Data.length * 0.75)}`); + console.log(`πŸ” DEBUG sendAudio: primi 50 chars base64="${base64Pcm16Data.substring(0, 50)}"`); + console.log(`πŸ” DEBUG sendAudio: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); + const msg: VoiceAudioMessage = { type: 'audio', data: base64Pcm16Data }; + const jsonMsg = JSON.stringify(msg); + console.log(`πŸ” DEBUG sendAudio: JSON message size=${jsonMsg.length} bytes`); + this.sendOrQueue(msg); + } + + /** + * Committa il buffer audio (opzionale - il server ha semantic VAD) + */ + sendAudioCommit(): void { + console.log(`πŸ” DEBUG sendAudioCommit: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); + const msg: VoiceAudioCommitMessage = { type: 'audio_commit' }; + this.sendOrQueue(msg); + console.log('πŸ” DEBUG sendAudioCommit: messaggio inviato'); + } + + /** + * Invia un messaggio di testo all'assistente + */ + sendText(content: string): void { + const msg: VoiceTextMessage = { type: 'text', content }; + this.sendOrQueue(msg); + } + + /** + * Interrompe la risposta corrente dell'assistente + */ + sendInterrupt(): void { + if (this.isConnected()) { + this.ws!.send(JSON.stringify({ type: 'interrupt' } as VoiceInterruptMessage)); + } + } + + /** + * Invia un messaggio o lo mette in coda se non ancora pronto + */ + private sendOrQueue(message: VoiceClientMessage): void { + if (!this.isConnected()) { + console.log(`πŸ” DEBUG sendOrQueue: BLOCCATO - non connesso (type=${message.type})`); + this.callbacks.onError?.('Connessione WebSocket non disponibile'); + return; + } + + if (!this.isReady()) { + console.log(`πŸ” DEBUG sendOrQueue: IN CODA - non pronto (type=${message.type}, authState=${this.authState})`); + this.messageQueue.push(message); + return; + } + + const json = JSON.stringify(message); + console.log(`πŸ” DEBUG sendOrQueue: INVIATO type=${message.type}, size=${json.length} bytes`); + this.ws!.send(json); + } + + /** + * Processa i messaggi in coda dopo che la sessione e' pronta + */ + private processQueuedMessages(): void { + if (this.messageQueue.length === 0) return; + + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message && this.isConnected()) { + this.ws!.send(JSON.stringify(message)); + } + } + } + + /** + * Valida una risposta WebSocket + */ + private validateResponse(response: any): response is VoiceServerMessage { + if (!response || typeof response !== 'object') return false; + + const validTypes = ['status', 'audio', 'transcript', 'tool_start', 'tool_end', 'error', 'done']; + if (!validTypes.includes(response.type)) return false; + + // Verifica lunghezza messaggi + if (response.message && typeof response.message === 'string' && response.message.length > 5000) { + console.warn('Messaggio troppo lungo ricevuto dal server'); + return false; + } + + // Verifica chunk audio + if (response.type === 'audio' && response.data) { + const approxBytes = Math.floor(response.data.length * 0.75); + if (approxBytes > MAX_AUDIO_CHUNK_BYTES) { + console.warn(`Chunk audio molto grande (~${approxBytes} bytes)`); + } + } + + return true; + } + + isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + isAuthenticated(): boolean { + return this.authState === WebSocketAuthState.AUTHENTICATED || + this.authState === WebSocketAuthState.READY; + } + + isReady(): boolean { + return this.authState === WebSocketAuthState.READY; + } + + getAuthState(): WebSocketAuthState { + return this.authState; + } + + private attemptReconnect(): void { + this.reconnectAttempts++; + const delay = this.reconnectDelay * this.reconnectAttempts; + + setTimeout(() => { + this.connect(); + }, delay); + } + + disconnect(): void { + this.clearAuthTimeout(); + this.messageQueue = []; + this.authState = WebSocketAuthState.DISCONNECTED; + + if (this.ws) { + this.ws.close(1000, 'Disconnessione volontaria'); + this.ws = null; + } + } + + destroy(): void { + this.disconnect(); + this.callbacks = {}; + this.reconnectAttempts = 0; + } +} diff --git a/test/test_botservice.ts b/test/test_botservice.ts index 3ef90dd..b17a749 100644 --- a/test/test_botservice.ts +++ b/test/test_botservice.ts @@ -1,4 +1,4 @@ -import { sendMessageToBot, validateMessage, formatMessage } from "../src/services/botservice"; +import { sendMessageToBot, validateMessage, formatMessage } from "../src/services/textBotService"; /** * Script di test TypeScript per sendMessageToBot From 71bc3bc4242d2c7928d7d0cb52057a180fcb58aa Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 2 Feb 2026 16:50:48 +0100 Subject: [PATCH 08/29] refactor: remove sendInterrupt and update audio sending mechanism to use binary frames --- src/components/BotChat/VoiceChatModal.tsx | 3 +- src/hooks/useVoiceChat.ts | 40 ++++----- src/services/voiceBotService.ts | 100 ++++++++++++---------- 3 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index afd7cec..4e6fd28 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -42,7 +42,6 @@ const VoiceChatModal: React.FC = ({ disconnect, cancelRecording, stopPlayback, - sendInterrupt, requestPermissions, } = useVoiceChat(); @@ -324,7 +323,7 @@ const VoiceChatModal: React.FC = ({ { - sendInterrupt(); + stopPlayback(); }} activeOpacity={0.7} > diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index fefecb8..d4636ab 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { VoiceBotWebSocket, VoiceChatCallbacks, VoiceServerPhase } from '../services/voiceBotService'; -import { AudioRecorder, AudioPlayer, checkAudioPermissions } from '../utils/audioUtils'; +import { AudioRecorder, AudioPlayer, checkAudioPermissions, base64ToArrayBuffer } from '../utils/audioUtils'; /** * Stati possibili della chat vocale @@ -262,7 +262,10 @@ export function useVoiceChat() { /** * Avvia la registrazione audio con streaming chunks via WebSocket. - * Ogni frame PCM16 a 24kHz viene inviato in tempo reale. + * Ogni frame PCM16 a 24kHz viene inviato in tempo reale come binary frame. + * + * IMPORTANTE: Il microfono invia audio continuamente. OpenAI gestisce + * automaticamente VAD e interruzioni. Non serve commit o interrupt manuale. */ const startRecording = useCallback(async (): Promise => { if (!audioRecorderRef.current || !websocketRef.current) { @@ -277,8 +280,14 @@ export function useVoiceChat() { try { // Callback invocato per ogni chunk audio PCM16 a 24kHz + // Converte base64 in ArrayBuffer e lo invia come binary frame const onChunk = (base64Chunk: string) => { - websocketRef.current?.sendAudio(base64Chunk); + try { + const arrayBuffer = base64ToArrayBuffer(base64Chunk); + websocketRef.current?.sendAudio(arrayBuffer); + } catch (error) { + console.error('Errore conversione chunk audio:', error); + } }; const started = await audioRecorderRef.current.startRecording(onChunk); @@ -307,8 +316,9 @@ export function useVoiceChat() { }, []); /** - * Ferma la registrazione e committa il buffer audio al server. - * I chunks sono gia' stati inviati in streaming durante la registrazione. + * Ferma la registrazione. + * I chunks sono giΓ  stati inviati in streaming durante la registrazione. + * Il VAD di OpenAI rileva automaticamente la fine della frase, non serve commit manuale. */ const stopRecording = useCallback(async (): Promise => { if (!audioRecorderRef.current || !websocketRef.current) return false; @@ -322,8 +332,7 @@ export function useVoiceChat() { try { await audioRecorderRef.current.stopRecording(); - websocketRef.current.sendAudioCommit(); - console.log('Buffer committato (chunks inviati in streaming)'); + console.log('Registrazione fermata (chunks giΓ  inviati in streaming, VAD automatico attivo)'); setState('processing'); setRecordingDuration(0); @@ -331,7 +340,7 @@ export function useVoiceChat() { } catch (err) { console.error('Errore stop registrazione:', err); - setError('Errore durante l\'invio dell\'audio'); + setError('Errore durante l\'arresto della registrazione'); setState('error'); return false; } @@ -366,20 +375,6 @@ export function useVoiceChat() { setState('ready'); }, []); - /** - * Interrompe la risposta corrente dell'assistente - */ - const sendInterrupt = useCallback((): void => { - if (websocketRef.current?.isConnected()) { - websocketRef.current.sendInterrupt(); - } - if (audioPlayerRef.current) { - audioPlayerRef.current.stopPlayback(); - audioPlayerRef.current.clearChunks(); - } - setState('ready'); - }, []); - /** * Invia un messaggio di testo all'assistente */ @@ -474,7 +469,6 @@ export function useVoiceChat() { stopRecording, cancelRecording, stopPlayback, - sendInterrupt, sendTextMessage, cleanup, requestPermissions, diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index a92dbb8..c936297 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -3,37 +3,26 @@ import { getValidToken } from "./authService"; // ============= VOICE CHAT WEBSOCKET (OpenAI Realtime API) ============= /** - * Tipi per i messaggi client -> server + * Tipi per i messaggi client -> server (JSON text frames) */ export interface VoiceAuthMessage { type: 'auth'; token: string; } -export interface VoiceAudioMessage { - type: 'audio'; - data: string; // base64 PCM16 -} - -export interface VoiceAudioCommitMessage { - type: 'audio_commit'; -} - export interface VoiceTextMessage { type: 'text'; content: string; } -export interface VoiceInterruptMessage { - type: 'interrupt'; -} - export type VoiceClientMessage = | VoiceAuthMessage - | VoiceAudioMessage - | VoiceAudioCommitMessage - | VoiceTextMessage - | VoiceInterruptMessage; + | VoiceTextMessage; + +/** + * NOTA: L'audio viene inviato come WebSocket binary frame (raw PCM16 bytes), + * NON come messaggio JSON. Vedere sendAudio() per i dettagli. + */ /** * Tipi per i messaggi server -> client @@ -124,11 +113,31 @@ export interface VoiceChatCallbacks { onDone?: () => void; } -const MAX_AUDIO_CHUNK_BYTES = 2_500_000; +/** + * Specifiche audio per il WebSocket vocale: + * - Formato: PCM16 (signed 16-bit little-endian) + * - Sample rate: 24000 Hz + * - Canali: 1 (mono) + * - Byte per sample: 2 + * - Dimensione chunk consigliata: 4800 bytes (100ms di audio @ 24kHz) + * - Intervallo invio: ogni 100ms + */ +export const VOICE_AUDIO_SAMPLE_RATE = 24000; +export const VOICE_AUDIO_CHANNELS = 1; +export const VOICE_AUDIO_BYTES_PER_SAMPLE = 2; +export const VOICE_RECOMMENDED_CHUNK_SIZE_BYTES = 4800; // 100ms @ 24kHz mono PCM16 +export const VOICE_CHUNK_INTERVAL_MS = 100; // Intervallo di invio consigliato +const MAX_AUDIO_CHUNK_BYTES = 2_500_000; // Safety limit per validazione /** * Classe per gestire la connessione WebSocket per la chat vocale * Compatibile con l'OpenAI Realtime API tramite il backend + * + * IMPORTANTE: + * - L'audio viene inviato come binary frame (raw PCM16 bytes) continuamente + * - Il VAD (Voice Activity Detection) Γ¨ gestito automaticamente da OpenAI + * - Non serve inviare messaggi di commit o interrupt (gestiti automaticamente) + * - Il microfono resta sempre attivo, anche durante le risposte dell'assistente */ export class VoiceBotWebSocket { private ws: WebSocket | null = null; @@ -363,26 +372,34 @@ export class VoiceBotWebSocket { } /** - * Invia un chunk audio PCM16 base64 al server + * Invia un chunk audio PCM16 raw al server come binary frame + * + * @param pcm16Data - Raw PCM16 bytes (ArrayBuffer o Uint8Array) + * Formato: 24kHz, mono, 16-bit little-endian + * Dimensione consigliata: 4800 bytes (100ms) + * + * IMPORTANTE: Il microfono deve inviare audio continuamente dal momento + * in cui si riceve "ready" fino alla chiusura del WebSocket. + * OpenAI gestisce automaticamente VAD e interruzioni. */ - sendAudio(base64Pcm16Data: string): void { - console.log(`πŸ” DEBUG sendAudio: base64 length=${base64Pcm16Data.length}, approx bytes=${Math.floor(base64Pcm16Data.length * 0.75)}`); - console.log(`πŸ” DEBUG sendAudio: primi 50 chars base64="${base64Pcm16Data.substring(0, 50)}"`); - console.log(`πŸ” DEBUG sendAudio: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); - const msg: VoiceAudioMessage = { type: 'audio', data: base64Pcm16Data }; - const jsonMsg = JSON.stringify(msg); - console.log(`πŸ” DEBUG sendAudio: JSON message size=${jsonMsg.length} bytes`); - this.sendOrQueue(msg); - } + sendAudio(pcm16Data: ArrayBuffer | Uint8Array): void { + if (!this.isConnected()) { + console.log(`πŸ” DEBUG sendAudio: BLOCCATO - non connesso`); + this.callbacks.onError?.('Connessione WebSocket non disponibile'); + return; + } - /** - * Committa il buffer audio (opzionale - il server ha semantic VAD) - */ - sendAudioCommit(): void { - console.log(`πŸ” DEBUG sendAudioCommit: WebSocket state=${this.ws?.readyState}, authState=${this.authState}, isReady=${this.isReady()}`); - const msg: VoiceAudioCommitMessage = { type: 'audio_commit' }; - this.sendOrQueue(msg); - console.log('πŸ” DEBUG sendAudioCommit: messaggio inviato'); + if (!this.isReady()) { + console.log(`πŸ” DEBUG sendAudio: BLOCCATO - non pronto (authState=${this.authState})`); + // Non mettiamo in coda l'audio, lo scartiamo + return; + } + + const bytes = pcm16Data instanceof Uint8Array ? pcm16Data.buffer : pcm16Data; + console.log(`πŸ” DEBUG sendAudio: invio binary frame size=${bytes.byteLength} bytes`); + + // Invia come binary frame (NON JSON) + this.ws!.send(bytes); } /** @@ -393,15 +410,6 @@ export class VoiceBotWebSocket { this.sendOrQueue(msg); } - /** - * Interrompe la risposta corrente dell'assistente - */ - sendInterrupt(): void { - if (this.isConnected()) { - this.ws!.send(JSON.stringify({ type: 'interrupt' } as VoiceInterruptMessage)); - } - } - /** * Invia un messaggio o lo mette in coda se non ancora pronto */ From 39143a1ff1ae264ea834359483849f430f5a631b Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 3 Feb 2026 11:17:28 +0100 Subject: [PATCH 09/29] refactor: streamline disconnect and cleanup processes in voice chat functionality --- src/components/BotChat/VoiceChatModal.tsx | 17 +++-- src/hooks/useVoiceChat.ts | 81 +++++++++++++++++++++-- src/services/voiceBotService.ts | 18 ++--- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 4e6fd28..f3efb3a 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -40,7 +40,6 @@ const VoiceChatModal: React.FC = ({ activeTools, connect, disconnect, - cancelRecording, stopPlayback, requestPermissions, } = useVoiceChat(); @@ -93,11 +92,10 @@ const VoiceChatModal: React.FC = ({ // Cleanup quando il modal si chiude useEffect(() => { if (!visible) { - if (isRecording) cancelRecording(); - if (isSpeaking) stopPlayback(); + // Usa disconnect che gestisce internamente il cleanup di registrazione e player disconnect(); } - }, [visible]); + }, [visible, disconnect]); // Animazione del cerchio pulsante - solo quando in ascolto useEffect(() => { @@ -180,11 +178,8 @@ const VoiceChatModal: React.FC = ({ await connect(); }; - const handleClose = () => { - if (isRecording) cancelRecording(); - if (isSpeaking) stopPlayback(); - disconnect(); - + const handleClose = async () => { + // Avvia l'animazione di chiusura Animated.parallel([ Animated.timing(slideIn, { toValue: height, @@ -199,6 +194,10 @@ const VoiceChatModal: React.FC = ({ ]).start(() => { onClose(); }); + + // Esegui il cleanup in parallelo all'animazione + // disconnect gestisce internamente il cleanup di registrazione e player + await disconnect(); }; const handleErrorDismiss = () => { diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index d4636ab..8ed97a2 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -123,8 +123,22 @@ export function useVoiceChat() { }, onConnectionClose: () => { + console.log('WebSocket disconnesso - cleanup in corso'); setState('disconnected'); shouldAutoStartRecordingRef.current = false; + + // Ferma la registrazione se attiva per evitare invio audio su connessione morta + if (audioRecorderRef.current?.isCurrentlyRecording()) { + audioRecorderRef.current.cancelRecording().catch(err => { + console.error('Errore fermando registrazione su disconnessione:', err); + }); + } + + // Pulisci il timer della durata + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } }, onStatus: (phase: VoiceServerPhase, message: string) => { @@ -388,7 +402,34 @@ export function useVoiceChat() { /** * Disconnette dal servizio */ - const disconnect = useCallback((): void => { + const disconnect = useCallback(async (): Promise => { + console.log('Disconnessione in corso...'); + + // Prima ferma la registrazione per evitare invio audio su connessione che sta chiudendo + if (audioRecorderRef.current?.isCurrentlyRecording()) { + try { + await audioRecorderRef.current.cancelRecording(); + } catch (err) { + console.error('Errore fermando registrazione durante disconnect:', err); + } + } + + // Pulisci il timer della durata + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + + // Ferma l'audio player + if (audioPlayerRef.current?.isCurrentlyPlaying()) { + try { + await audioPlayerRef.current.stopPlayback(); + } catch (err) { + console.error('Errore fermando playback durante disconnect:', err); + } + } + + // Poi chiudi il WebSocket if (websocketRef.current) { websocketRef.current.disconnect(); } @@ -398,23 +439,49 @@ export function useVoiceChat() { setError(null); setTranscripts([]); setActiveTools([]); + setRecordingDuration(0); }, []); /** * Pulisce tutte le risorse */ const cleanup = useCallback(async (): Promise => { + console.log('Cleanup risorse voice chat...'); + + // Pulisci il timer della durata + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + + // Prima ferma la registrazione if (audioRecorderRef.current) { - await audioRecorderRef.current.cancelRecording(); + try { + await audioRecorderRef.current.cancelRecording(); + } catch (err) { + console.error('Errore cleanup registrazione:', err); + } + audioRecorderRef.current = null; } + + // Poi ferma il player if (audioPlayerRef.current) { - await audioPlayerRef.current.destroy(); + try { + await audioPlayerRef.current.destroy(); + } catch (err) { + console.error('Errore cleanup player:', err); + } + audioPlayerRef.current = null; } + + // Infine chiudi il WebSocket if (websocketRef.current) { - websocketRef.current.destroy(); - } - if (recordingIntervalRef.current) { - clearInterval(recordingIntervalRef.current); + try { + websocketRef.current.destroy(); + } catch (err) { + console.error('Errore cleanup websocket:', err); + } + websocketRef.current = null; } setState('idle'); diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index c936297..d50f272 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -383,20 +383,13 @@ export class VoiceBotWebSocket { * OpenAI gestisce automaticamente VAD e interruzioni. */ sendAudio(pcm16Data: ArrayBuffer | Uint8Array): void { - if (!this.isConnected()) { - console.log(`πŸ” DEBUG sendAudio: BLOCCATO - non connesso`); - this.callbacks.onError?.('Connessione WebSocket non disponibile'); - return; - } - - if (!this.isReady()) { - console.log(`πŸ” DEBUG sendAudio: BLOCCATO - non pronto (authState=${this.authState})`); - // Non mettiamo in coda l'audio, lo scartiamo + // Non logghiamo errori se la connessione Γ¨ giΓ  chiusa per evitare spam di log + // In questo caso semplicemente scartiamo l'audio silenziosamente + if (!this.isConnected() || !this.isReady()) { return; } const bytes = pcm16Data instanceof Uint8Array ? pcm16Data.buffer : pcm16Data; - console.log(`πŸ” DEBUG sendAudio: invio binary frame size=${bytes.byteLength} bytes`); // Invia come binary frame (NON JSON) this.ws!.send(bytes); @@ -415,19 +408,16 @@ export class VoiceBotWebSocket { */ private sendOrQueue(message: VoiceClientMessage): void { if (!this.isConnected()) { - console.log(`πŸ” DEBUG sendOrQueue: BLOCCATO - non connesso (type=${message.type})`); - this.callbacks.onError?.('Connessione WebSocket non disponibile'); + // Non logghiamo errori se la connessione Γ¨ giΓ  chiusa per evitare spam di log return; } if (!this.isReady()) { - console.log(`πŸ” DEBUG sendOrQueue: IN CODA - non pronto (type=${message.type}, authState=${this.authState})`); this.messageQueue.push(message); return; } const json = JSON.stringify(message); - console.log(`πŸ” DEBUG sendOrQueue: INVIATO type=${message.type}, size=${json.length} bytes`); this.ws!.send(json); } From 82801f2fd17a2312525d3114e85e8bff4a1ac29e Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 3 Feb 2026 14:17:07 +0100 Subject: [PATCH 10/29] refactor: enhance voice chat state management and recording logic --- src/hooks/useVoiceChat.ts | 62 +++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 8ed97a2..964d8d5 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -66,6 +66,7 @@ export function useVoiceChat() { const audioPlayerRef = useRef(null); const recordingIntervalRef = useRef(null); const shouldAutoStartRecordingRef = useRef(false); + const agentEndedRef = useRef(true); // true = agent ha finito, possiamo registrare /** * Verifica e richiede i permessi audio @@ -147,24 +148,58 @@ export function useVoiceChat() { switch (phase) { case 'agent_start': setState('processing'); + agentEndedRef.current = false; // Agent sta elaborando + // IMPORTANTE: Ferma la registrazione quando l'assistente inizia a rispondere + // per evitare che il microfono catturi l'audio della risposta (feedback loop) + if (audioRecorderRef.current?.isCurrentlyRecording()) { + console.log('Fermo registrazione - agent_start'); + audioRecorderRef.current.stopRecording().catch(err => { + console.error('Errore fermando registrazione su agent_start:', err); + }); + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + } break; case 'agent_end': - // Agent ha finito di elaborare, l'audio potrebbe seguire + // Agent ha finito di elaborare + console.log('Agent terminato'); + agentEndedRef.current = true; + // Se ci sono chunk audio da riprodurre, aspettiamo audio_end + // Altrimenti, torniamo pronti + if (!audioPlayerRef.current || audioPlayerRef.current.getChunksCount() === 0) { + setState('ready'); + setTimeout(() => { + startRecording(); + }, 300); + } break; case 'audio_end': - // Server ha finito di inviare chunk audio -> riproduci + // Server ha finito di inviare chunk audio per questo segmento if (audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0) { setState('speaking'); + console.log(`Avvio riproduzione audio (${audioPlayerRef.current.getChunksCount()} chunk)`); audioPlayerRef.current.playPcm16Chunks(() => { - setState('ready'); - // Riavvia automaticamente la registrazione per il prossimo turno - setTimeout(() => { - startRecording(); - }, 300); + console.log('Riproduzione completata'); + // Riavvia la registrazione SOLO se l'agent ha finito + if (agentEndedRef.current) { + console.log('Agent finito, riavvio registrazione'); + setState('ready'); + setTimeout(() => { + startRecording(); + }, 300); + } else { + // Agent non ha ancora finito, torna in processing + // e aspetta altri chunk audio o agent_end + console.log('Agent non ancora finito, attendo...'); + setState('processing'); + } }); - } else { + } else if (agentEndedRef.current) { + // Nessun audio da riprodurre e agent finito, torna pronto setState('ready'); setTimeout(() => { startRecording(); @@ -173,12 +208,20 @@ export function useVoiceChat() { break; case 'interrupted': - // Risposta interrotta, torna pronto + // Risposta interrotta dall'utente, torna pronto + console.log('Risposta interrotta'); + agentEndedRef.current = true; // Reset if (audioPlayerRef.current) { audioPlayerRef.current.stopPlayback(); audioPlayerRef.current.clearChunks(); } setState('ready'); + // L'utente ha interrotto, riavvia la registrazione + if (!audioRecorderRef.current?.isCurrentlyRecording()) { + setTimeout(() => { + startRecording(); + }, 200); + } break; } }, @@ -254,6 +297,7 @@ export function useVoiceChat() { setActiveTools([]); setChunksReceived(0); shouldAutoStartRecordingRef.current = true; + agentEndedRef.current = true; // Reset per nuova sessione try { const connected = await websocketRef.current!.connect(); From 10237a27c1136c0fd0cd1ef65a38664882ac5439 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 3 Feb 2026 17:10:44 +0100 Subject: [PATCH 11/29] refactor: enhance voice chat functionality with mute/unmute feature and animations --- src/components/BotChat/VoiceChatModal.tsx | 527 ++++++++++++++++------ src/hooks/useVoiceChat.ts | 87 +++- 2 files changed, 464 insertions(+), 150 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index f3efb3a..8d57132 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState } from "react"; import { View, Text, @@ -9,9 +9,11 @@ import { Dimensions, StatusBar, ActivityIndicator, - Alert + Alert, + Platform } from "react-native"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; +import { LinearGradient } from 'expo-linear-gradient'; import { useVoiceChat } from '../../hooks/useVoiceChat'; export interface VoiceChatModalProps { @@ -38,10 +40,13 @@ const VoiceChatModal: React.FC = ({ isSpeaking, transcripts, activeTools, + isMuted, connect, disconnect, stopPlayback, requestPermissions, + mute, + unmute, } = useVoiceChat(); // Animazioni @@ -50,6 +55,8 @@ const VoiceChatModal: React.FC = ({ const slideIn = useRef(new Animated.Value(height)).current; const fadeIn = useRef(new Animated.Value(0)).current; const recordingScale = useRef(new Animated.Value(1)).current; + const breathingScale = useRef(new Animated.Value(1)).current; + const liveDotOpacity = useRef(new Animated.Value(1)).current; // Notifica trascrizioni assistant al parent useEffect(() => { @@ -162,6 +169,57 @@ const VoiceChatModal: React.FC = ({ } }, [isProcessing, isSpeaking, recordingScale]); + // Breathing animation for idle state + useEffect(() => { + const shouldBreathe = isConnected && !isRecording && !isProcessing && !isSpeaking && state === 'ready'; + + if (shouldBreathe) { + const breathingAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(breathingScale, { + toValue: 1.05, + duration: 2000, + useNativeDriver: true, + }), + Animated.timing(breathingScale, { + toValue: 1, + duration: 2000, + useNativeDriver: true, + }), + ]) + ); + breathingAnimation.start(); + + return () => { + breathingAnimation.stop(); + breathingScale.setValue(1); + }; + } + }, [isConnected, isRecording, isProcessing, isSpeaking, state, breathingScale]); + + // Live dot pulse animation + useEffect(() => { + if (isConnected) { + const dotPulse = Animated.loop( + Animated.sequence([ + Animated.timing(liveDotOpacity, { + toValue: 0.3, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(liveDotOpacity, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + dotPulse.start(); + + return () => dotPulse.stop(); + } + }, [isConnected, liveDotOpacity]); + // Gestione connessione const handleConnect = async () => { if (!hasPermissions) { @@ -249,11 +307,17 @@ const VoiceChatModal: React.FC = ({ if (isProcessing || isSpeaking) { return ( - + + + ); } @@ -261,8 +325,15 @@ const VoiceChatModal: React.FC = ({ // Stato: connessione / setup if (state === 'connecting' || state === 'authenticating' || state === 'setting_up') { return ( - - + + + + ); } @@ -270,14 +341,23 @@ const VoiceChatModal: React.FC = ({ // Stato: errore if (state === 'error') { return ( - - + - - + + + + ); } @@ -285,29 +365,48 @@ const VoiceChatModal: React.FC = ({ // Stato: non connesso if (!isConnected) { return ( - - + - - + + + + ); } - // Stato: ascolto attivo con animazione semplice + // Determine scale for idle state + const orbScale = isRecording ? 1 : breathingScale; + + // Stato: ascolto attivo o pronto return ( - + + + ); }; @@ -339,7 +438,7 @@ const VoiceChatModal: React.FC = ({ statusBarTranslucent={true} onRequestClose={handleClose} > - + = ({ }, ]} > - {/* Header */} + {/* Header with Live indicator and Close button */} + {/* Live Indicator */} + {isConnected && ( + + + Live + + )} + + {/* Close Button */} - + {/* Contenuto principale */} - {/* Titolo minimale */} - Assistente Vocale - - {/* Messaggio di stato semplice */} + {/* Messaggio di stato */} {renderStateIndicator()} - {/* Cerchio animato centrale */} - + {/* Orb animato centrale */} + {/* Cerchi di pulsazione - solo quando in ascolto */} {isRecording && ( <> @@ -397,17 +509,21 @@ const VoiceChatModal: React.FC = ({ )} - {/* Pulsante principale */} + {/* Orb principale */} {renderMainButton()} - - {/* Pulsante stop durante elaborazione */} - {renderStopButton()} - - {/* Ultima trascrizione */} - {renderLastTranscript()} + {/* Overlay microfono disabilitato */} + {isMuted && isConnected && ( + + + + Microfono disattivato + + + )} + - {/* Messaggio di errore minimalista */} + {/* Messaggio di errore */} {error && ( {error} @@ -418,11 +534,56 @@ const VoiceChatModal: React.FC = ({ )} - {/* Footer con istruzioni semplici */} - - - Parla naturalmente - + {/* Widget Area - Transcript */} + {transcripts.length > 0 && ( + + Trascrizione + + {transcripts[transcripts.length - 1].content} + + + )} + + {/* Bottom Control Bar */} + + {/* Microphone Button - Primary */} + { + if (isMuted) { + unmute(); + } else { + mute(); + } + }} + disabled={!isConnected || state === 'connecting' || isProcessing || isSpeaking} + activeOpacity={0.8} + accessibilityRole="button" + accessibilityLabel={isMuted ? "Microfono disattivato" : "Microfono attivo"} + accessibilityState={{ selected: !isMuted }} + > + + + + {/* End Call Button */} + + + @@ -432,19 +593,45 @@ const VoiceChatModal: React.FC = ({ const styles = StyleSheet.create({ overlay: { flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.95)", + backgroundColor: "#000000", justifyContent: "space-between", }, header: { paddingTop: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 44, paddingHorizontal: 20, paddingBottom: 16, - alignItems: "flex-end", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + liveIndicator: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(255, 255, 255, 0.1)", + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, + liveDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: "#34C759", + marginRight: 6, + }, + liveText: { + fontSize: 13, + fontWeight: "600", + color: "#FFFFFF", + fontFamily: "System", }, closeButton: { - padding: 10, - borderRadius: 24, - backgroundColor: "rgba(255, 255, 255, 0.06)", + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "rgba(58, 58, 60, 0.6)", + justifyContent: "center", + alignItems: "center", }, content: { flex: 1, @@ -452,125 +639,193 @@ const styles = StyleSheet.create({ alignItems: "center", paddingHorizontal: 32, }, - title: { - fontSize: 26, - fontWeight: "200", - color: "#ffffff", - textAlign: "center", - marginBottom: 64, - fontFamily: "System", - letterSpacing: 0.8, - }, subtleText: { fontSize: 15, fontWeight: "300", color: "rgba(255, 255, 255, 0.5)", textAlign: "center", - marginBottom: 52, + marginBottom: 40, fontFamily: "System", }, - transcriptText: { - fontSize: 13, - fontWeight: "300", - color: "rgba(255, 255, 255, 0.4)", - textAlign: "center", - marginTop: 32, - maxWidth: "85%", - fontFamily: "System", - }, - microphoneContainer: { + orbOuterContainer: { position: "relative", alignItems: "center", justifyContent: "center", - marginVertical: 48, + marginVertical: 32, }, pulseCircle: { position: "absolute", - borderRadius: 150, - borderWidth: 1, - borderColor: "rgba(76, 175, 80, 0.4)", + borderRadius: 200, + borderWidth: 2, + borderColor: "rgba(0, 102, 255, 0.3)", }, pulseCircle1: { + width: 280, + height: 280, + }, + pulseCircle2: { + width: 340, + height: 340, + }, + orbContainer: { width: 240, height: 240, + borderRadius: 120, + ...Platform.select({ + ios: { + shadowColor: "#0066FF", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.6, + shadowRadius: 24, + }, + android: { + elevation: 12, + }, + }), }, - pulseCircle2: { - width: 300, - height: 300, - }, - microphoneCircle: { - width: 160, - height: 160, - borderRadius: 80, - backgroundColor: "rgba(255, 255, 255, 0.08)", + orbGradient: { + width: 240, + height: 240, + borderRadius: 120, justifyContent: "center", alignItems: "center", - borderWidth: 1.5, - borderColor: "rgba(255, 255, 255, 0.15)", }, - listeningCircle: { - backgroundColor: "rgba(76, 175, 80, 0.15)", - borderColor: "rgba(76, 175, 80, 0.3)", - }, - thinkingCircle: { - backgroundColor: "rgba(33, 150, 243, 0.15)", - borderColor: "rgba(33, 150, 243, 0.3)", - }, - microphoneButton: { + orbButton: { width: "100%", height: "100%", justifyContent: "center", alignItems: "center", - borderRadius: 80, + borderRadius: 120, }, - footer: { - paddingHorizontal: 40, - paddingBottom: 64, + errorContainer: { alignItems: "center", + paddingHorizontal: 24, + paddingVertical: 16, + backgroundColor: "rgba(244, 67, 54, 0.1)", + borderRadius: 16, + marginTop: 32, + maxWidth: "85%", }, - footerText: { - fontSize: 12, - fontWeight: "300", - color: "rgba(255, 255, 255, 0.35)", + errorText: { + color: "#FF6B6B", + fontSize: 13, + fontWeight: "400", textAlign: "center", + marginBottom: 12, fontFamily: "System", - letterSpacing: 0.3, }, - stopButton: { - paddingHorizontal: 28, - paddingVertical: 14, - backgroundColor: "rgba(255, 255, 255, 0.08)", - borderRadius: 28, - marginTop: 40, + retryText: { + color: "#FFFFFF", + fontSize: 13, + fontWeight: "600", + fontFamily: "System", + }, + widgetArea: { + height: 120, + backgroundColor: "rgba(28, 28, 30, 0.95)", + marginHorizontal: 16, + marginBottom: 16, + borderRadius: 16, + padding: 16, + justifyContent: "flex-start", }, - stopButtonText: { + widgetTitle: { + fontSize: 12, + fontWeight: "600", + color: "rgba(255, 255, 255, 0.6)", + marginBottom: 8, + fontFamily: "System", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + widgetText: { fontSize: 14, fontWeight: "400", - color: "rgba(255, 255, 255, 0.75)", + color: "#FFFFFF", fontFamily: "System", - letterSpacing: 0.2, + lineHeight: 20, }, - errorContainer: { + controlBar: { + flexDirection: "row", + justifyContent: "center", alignItems: "center", - paddingHorizontal: 28, - paddingVertical: 18, - backgroundColor: "rgba(244, 67, 54, 0.08)", - borderRadius: 20, - marginTop: 40, - maxWidth: "85%", + gap: 40, + paddingHorizontal: 32, + paddingVertical: 24, + paddingBottom: 40, + backgroundColor: "#000000", }, - errorText: { - color: "rgba(255, 107, 107, 0.85)", - fontSize: 13, - fontWeight: "300", - textAlign: "center", - marginBottom: 14, - fontFamily: "System", + controlButton: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: "rgba(58, 58, 60, 0.8)", + justifyContent: "center", + alignItems: "center", }, - retryText: { - color: "rgba(255, 255, 255, 0.65)", - fontSize: 13, - fontWeight: "400", + controlButtonDisabled: { + backgroundColor: "rgba(58, 58, 60, 0.4)", + }, + controlButtonActive: { + backgroundColor: "rgba(58, 58, 60, 1)", + }, + controlButtonPrimary: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: "#0066FF", + ...Platform.select({ + ios: { + shadowColor: "#0066FF", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 8, + }, + android: { + elevation: 8, + }, + }), + }, + controlButtonRecording: { + backgroundColor: "#00CCFF", + }, + controlButtonMuted: { + backgroundColor: "#FF3B30", + }, + controlButtonEnd: { + backgroundColor: "#FF3B30", + ...Platform.select({ + ios: { + shadowColor: "#FF3B30", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + }, + android: { + elevation: 6, + }, + }), + }, + mutedOverlay: { + position: "absolute", + width: 240, + height: 240, + borderRadius: 120, + backgroundColor: "rgba(0, 0, 0, 0.85)", + justifyContent: "center", + alignItems: "center", + zIndex: 10, + }, + mutedIconContainer: { + alignItems: "center", + justifyContent: "center", + }, + mutedText: { + fontSize: 12, + fontWeight: "600", + color: "#FF3B30", + marginTop: 8, + textAlign: "center", fontFamily: "System", }, }); diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 964d8d5..0afd723 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -55,6 +55,7 @@ export function useVoiceChat() { const [recordingDuration, setRecordingDuration] = useState(0); const [hasPermissions, setHasPermissions] = useState(false); const [chunksReceived, setChunksReceived] = useState(0); + const [isMuted, setIsMuted] = useState(false); // Trascrizioni e tool const [transcripts, setTranscripts] = useState([]); @@ -67,6 +68,7 @@ export function useVoiceChat() { const recordingIntervalRef = useRef(null); const shouldAutoStartRecordingRef = useRef(false); const agentEndedRef = useRef(true); // true = agent ha finito, possiamo registrare + const isMutedRef = useRef(false); /** * Verifica e richiede i permessi audio @@ -108,12 +110,14 @@ export function useVoiceChat() { console.log('Sessione vocale pronta'); setState('ready'); - // Avvia la registrazione automaticamente se richiesto - if (shouldAutoStartRecordingRef.current) { + // Avvia la registrazione automaticamente se richiesto e non mutato + if (shouldAutoStartRecordingRef.current && !isMutedRef.current) { shouldAutoStartRecordingRef.current = false; setTimeout(() => { startRecording(); }, 500); + } else if (isMutedRef.current) { + shouldAutoStartRecordingRef.current = false; } }, @@ -171,9 +175,11 @@ export function useVoiceChat() { // Altrimenti, torniamo pronti if (!audioPlayerRef.current || audioPlayerRef.current.getChunksCount() === 0) { setState('ready'); - setTimeout(() => { - startRecording(); - }, 300); + if (!isMutedRef.current) { + setTimeout(() => { + startRecording(); + }, 300); + } } break; @@ -184,13 +190,15 @@ export function useVoiceChat() { console.log(`Avvio riproduzione audio (${audioPlayerRef.current.getChunksCount()} chunk)`); audioPlayerRef.current.playPcm16Chunks(() => { console.log('Riproduzione completata'); - // Riavvia la registrazione SOLO se l'agent ha finito + // Riavvia la registrazione SOLO se l'agent ha finito e non Γ¨ mutato if (agentEndedRef.current) { console.log('Agent finito, riavvio registrazione'); setState('ready'); - setTimeout(() => { - startRecording(); - }, 300); + if (!isMutedRef.current) { + setTimeout(() => { + startRecording(); + }, 300); + } } else { // Agent non ha ancora finito, torna in processing // e aspetta altri chunk audio o agent_end @@ -201,9 +209,11 @@ export function useVoiceChat() { } else if (agentEndedRef.current) { // Nessun audio da riprodurre e agent finito, torna pronto setState('ready'); - setTimeout(() => { - startRecording(); - }, 300); + if (!isMutedRef.current) { + setTimeout(() => { + startRecording(); + }, 300); + } } break; @@ -216,8 +226,8 @@ export function useVoiceChat() { audioPlayerRef.current.clearChunks(); } setState('ready'); - // L'utente ha interrotto, riavvia la registrazione - if (!audioRecorderRef.current?.isCurrentlyRecording()) { + // L'utente ha interrotto, riavvia la registrazione se non mutato + if (!audioRecorderRef.current?.isCurrentlyRecording() && !isMutedRef.current) { setTimeout(() => { startRecording(); }, 200); @@ -443,6 +453,50 @@ export function useVoiceChat() { } }, []); + /** + * Muta il microfono + */ + const mute = useCallback(async (): Promise => { + setIsMuted(true); + isMutedRef.current = true; + + // Ferma la registrazione se Γ¨ attiva + if (audioRecorderRef.current?.isCurrentlyRecording()) { + try { + await audioRecorderRef.current.cancelRecording(); + + // Pulisci il timer della durata + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + + setRecordingDuration(0); + // Mantieni lo stato 'ready' invece di tornare a 'recording' + if (state === 'recording') { + setState('ready'); + } + } catch (err) { + console.error('Errore durante il mute:', err); + } + } + }, [state]); + + /** + * Riattiva il microfono + */ + const unmute = useCallback(async (): Promise => { + setIsMuted(false); + isMutedRef.current = false; + + // Riavvia la registrazione se siamo in stato 'ready' + if (state === 'ready' && websocketRef.current?.isReady()) { + setTimeout(() => { + startRecording(); + }, 100); + } + }, [state, startRecording]); + /** * Disconnette dal servizio */ @@ -484,6 +538,8 @@ export function useVoiceChat() { setTranscripts([]); setActiveTools([]); setRecordingDuration(0); + setIsMuted(false); // Reset mute state + isMutedRef.current = false; }, []); /** @@ -559,6 +615,7 @@ export function useVoiceChat() { recordingDuration, hasPermissions, chunksReceived, + isMuted, // Trascrizioni e tool transcripts, @@ -583,5 +640,7 @@ export function useVoiceChat() { sendTextMessage, cleanup, requestPermissions, + mute, + unmute, }; } From 95c6e21b961888e18782cb20f880e4b46a8ab4ee Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Wed, 4 Feb 2026 16:32:27 +0100 Subject: [PATCH 12/29] refactor: enhance voice chat message handling with unified message structure and tool call/output integration --- src/components/BotChat/VoiceChatModal.tsx | 680 ++++++++++------------ src/hooks/useVoiceChat.ts | 7 +- src/services/voiceBotService.ts | 36 +- 3 files changed, 324 insertions(+), 399 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 8d57132..008531d 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState } from "react"; +import React, { useRef, useEffect, useState, useMemo } from "react"; import { View, Text, @@ -10,12 +10,24 @@ import { StatusBar, ActivityIndicator, Alert, - Platform + Platform, + FlatList } from "react-native"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { LinearGradient } from 'expo-linear-gradient'; import { useVoiceChat } from '../../hooks/useVoiceChat'; +// Tipi per i messaggi della voice chat +type VoiceChatMessageType = + | { type: 'transcript'; role: 'user' | 'assistant'; content: string; timestamp: Date } + | { type: 'tool_call'; toolName: string; args: string; timestamp: Date } + | { type: 'tool_output'; toolName: string; output: string; timestamp: Date }; + +interface VoiceChatMessage { + id: string; + data: VoiceChatMessageType; +} + export interface VoiceChatModalProps { visible: boolean; onClose: () => void; @@ -50,13 +62,70 @@ const VoiceChatModal: React.FC = ({ } = useVoiceChat(); // Animazioni - const pulseScale = useRef(new Animated.Value(1)).current; - const pulseOpacity = useRef(new Animated.Value(0.3)).current; const slideIn = useRef(new Animated.Value(height)).current; const fadeIn = useRef(new Animated.Value(0)).current; - const recordingScale = useRef(new Animated.Value(1)).current; - const breathingScale = useRef(new Animated.Value(1)).current; const liveDotOpacity = useRef(new Animated.Value(1)).current; + const flatListRef = useRef(null); + + // Combina trascrizioni e tool calls/outputs in un'unica lista di messaggi + const chatMessages = useMemo(() => { + const messages: VoiceChatMessage[] = []; + let messageIndex = 0; + + // Aggiungi trascrizioni + transcripts.forEach((transcript, idx) => { + messages.push({ + id: `transcript-${idx}`, + data: { + type: 'transcript', + role: transcript.role, + content: transcript.content, + timestamp: new Date() + } + }); + messageIndex++; + }); + + // Aggiungi tool calls e outputs + activeTools.forEach((tool, idx) => { + // Tool call + messages.push({ + id: `tool-call-${idx}`, + data: { + type: 'tool_call', + toolName: tool.name, + args: tool.args, + timestamp: new Date() + } + }); + messageIndex++; + + // Tool output (se presente) + if (tool.status === 'complete' && tool.output) { + messages.push({ + id: `tool-output-${idx}`, + data: { + type: 'tool_output', + toolName: tool.name, + output: tool.output, + timestamp: new Date() + } + }); + messageIndex++; + } + }); + + return messages; + }, [transcripts, activeTools]); + + // Auto-scroll quando arrivano nuovi messaggi + useEffect(() => { + if (chatMessages.length > 0) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [chatMessages.length]); // Notifica trascrizioni assistant al parent useEffect(() => { @@ -104,99 +173,6 @@ const VoiceChatModal: React.FC = ({ } }, [visible, disconnect]); - // Animazione del cerchio pulsante - solo quando in ascolto - useEffect(() => { - const shouldAnimate = isRecording; - - if (shouldAnimate) { - const pulseAnimation = Animated.loop( - Animated.sequence([ - Animated.parallel([ - Animated.timing(pulseScale, { - toValue: 1.15, - duration: 800, - useNativeDriver: true, - }), - Animated.timing(pulseOpacity, { - toValue: 0, - duration: 800, - useNativeDriver: true, - }), - ]), - Animated.parallel([ - Animated.timing(pulseScale, { - toValue: 1, - duration: 800, - useNativeDriver: true, - }), - Animated.timing(pulseOpacity, { - toValue: 0.4, - duration: 800, - useNativeDriver: true, - }), - ]), - ]) - ); - pulseAnimation.start(); - - return () => pulseAnimation.stop(); - } - }, [isRecording, pulseScale, pulseOpacity]); - - // Animazione durante elaborazione/risposta - useEffect(() => { - if (isProcessing || isSpeaking) { - const thinkingAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(recordingScale, { - toValue: 1.08, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(recordingScale, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - ]) - ); - thinkingAnimation.start(); - - return () => { - thinkingAnimation.stop(); - recordingScale.setValue(1); - }; - } - }, [isProcessing, isSpeaking, recordingScale]); - - // Breathing animation for idle state - useEffect(() => { - const shouldBreathe = isConnected && !isRecording && !isProcessing && !isSpeaking && state === 'ready'; - - if (shouldBreathe) { - const breathingAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(breathingScale, { - toValue: 1.05, - duration: 2000, - useNativeDriver: true, - }), - Animated.timing(breathingScale, { - toValue: 1, - duration: 2000, - useNativeDriver: true, - }), - ]) - ); - breathingAnimation.start(); - - return () => { - breathingAnimation.stop(); - breathingScale.setValue(1); - }; - } - }, [isConnected, isRecording, isProcessing, isSpeaking, state, breathingScale]); - // Live dot pulse animation useEffect(() => { if (isConnected) { @@ -290,144 +266,63 @@ const VoiceChatModal: React.FC = ({ } }; - // Mostra l'ultima trascrizione - const renderLastTranscript = () => { - if (transcripts.length === 0) return null; - const last = transcripts[transcripts.length - 1]; - return ( - - {last.role === 'user' ? 'Tu: ' : ''}{last.content} - - ); - }; + // Render di un singolo messaggio della chat + const renderChatMessage = ({ item }: { item: VoiceChatMessage }) => { + const { data } = item; - // Render del pulsante principale - const renderMainButton = () => { - // Stato: elaborazione o risposta in corso - if (isProcessing || isSpeaking) { + if (data.type === 'transcript') { + const isUser = data.role === 'user'; return ( - - - - - - ); - } - - // Stato: connessione / setup - if (state === 'connecting' || state === 'authenticating' || state === 'setting_up') { - return ( - - - - - + + + {data.content} + + + ); } - // Stato: errore - if (state === 'error') { + if (data.type === 'tool_call') { return ( - - - - - - - + + + + + Esecuzione tool + + {data.toolName} + {data.args} + + ); } - // Stato: non connesso - if (!isConnected) { + if (data.type === 'tool_output') { return ( - - - - - - - + + + + + Risultato + + {data.toolName} + {data.output} + + ); } - // Determine scale for idle state - const orbScale = isRecording ? 1 : breathingScale; - - // Stato: ascolto attivo o pronto - return ( - - - - - - ); - }; - - // Render pulsante di stop durante elaborazione/risposta - const renderStopButton = () => { - if (!isProcessing && !isSpeaking) { - return null; - } - - return ( - { - stopPlayback(); - }} - activeOpacity={0.7} - > - Interrompi - - ); + return null; }; return ( @@ -476,74 +371,63 @@ const VoiceChatModal: React.FC = ({ - {/* Contenuto principale */} + {/* Contenuto principale - Chat */} - {/* Messaggio di stato */} - {renderStateIndicator()} - - {/* Orb animato centrale */} - - {/* Cerchi di pulsazione - solo quando in ascolto */} - {isRecording && ( - <> - - - - )} - - {/* Orb principale */} - {renderMainButton()} - - {/* Overlay microfono disabilitato */} + {/* Messaggio di stato in alto */} + + {renderStateIndicator()} {isMuted && isConnected && ( - - - - Microfono disattivato - + + + Muto )} + {/* Lista messaggi */} + {chatMessages.length > 0 ? ( + item.id} + contentContainerStyle={styles.chatList} + showsVerticalScrollIndicator={false} + onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} + /> + ) : ( + + {state === 'connecting' || state === 'authenticating' || state === 'setting_up' ? ( + <> + + Connessione in corso... + + ) : state === 'error' ? ( + <> + + Errore di connessione + + Riprova + + + ) : ( + <> + + Inizia a parlare + La conversazione apparirΓ  qui + + )} + + )} + {/* Messaggio di errore */} {error && ( {error} - - Riprova - )} - {/* Widget Area - Transcript */} - {transcripts.length > 0 && ( - - Trascrizione - - {transcripts[transcripts.length - 1].content} - - - )} - {/* Bottom Control Bar */} {/* Microphone Button - Primary */} @@ -635,115 +519,177 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - justifyContent: "center", + paddingHorizontal: 0, + }, + statusBar: { + flexDirection: "row", + justifyContent: "space-between", alignItems: "center", - paddingHorizontal: 32, + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: "rgba(0, 0, 0, 0.3)", }, subtleText: { - fontSize: 15, + fontSize: 13, fontWeight: "300", - color: "rgba(255, 255, 255, 0.5)", - textAlign: "center", - marginBottom: 40, + color: "rgba(255, 255, 255, 0.7)", fontFamily: "System", }, - orbOuterContainer: { - position: "relative", + mutedBadge: { + flexDirection: "row", alignItems: "center", + backgroundColor: "rgba(255, 59, 48, 0.2)", + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + gap: 4, + }, + mutedBadgeText: { + fontSize: 11, + fontWeight: "600", + color: "#FF3B30", + fontFamily: "System", + }, + chatList: { + paddingHorizontal: 16, + paddingVertical: 16, + paddingBottom: 32, + }, + emptyChat: { + flex: 1, justifyContent: "center", - marginVertical: 32, + alignItems: "center", + paddingHorizontal: 32, }, - pulseCircle: { - position: "absolute", - borderRadius: 200, - borderWidth: 2, - borderColor: "rgba(0, 102, 255, 0.3)", + emptyChatText: { + fontSize: 18, + fontWeight: "500", + color: "#FFFFFF", + textAlign: "center", + marginBottom: 8, + fontFamily: "System", }, - pulseCircle1: { - width: 280, - height: 280, + emptyChatSubtext: { + fontSize: 14, + fontWeight: "300", + color: "rgba(255, 255, 255, 0.6)", + textAlign: "center", + fontFamily: "System", }, - pulseCircle2: { - width: 340, - height: 340, + retryButton: { + marginTop: 16, + paddingHorizontal: 24, + paddingVertical: 12, + backgroundColor: "#0066FF", + borderRadius: 20, }, - orbContainer: { - width: 240, - height: 240, - borderRadius: 120, - ...Platform.select({ - ios: { - shadowColor: "#0066FF", - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.6, - shadowRadius: 24, - }, - android: { - elevation: 12, - }, - }), + retryButtonText: { + fontSize: 15, + fontWeight: "600", + color: "#FFFFFF", + fontFamily: "System", }, - orbGradient: { - width: 240, - height: 240, - borderRadius: 120, - justifyContent: "center", - alignItems: "center", + chatMessageContainer: { + marginVertical: 6, }, - orbButton: { - width: "100%", - height: "100%", - justifyContent: "center", - alignItems: "center", - borderRadius: 120, + userMessageContainer: { + alignItems: "flex-end", }, - errorContainer: { + assistantMessageContainer: { + alignItems: "flex-start", + }, + chatMessageBubble: { + maxWidth: "80%", + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 18, + }, + userBubble: { + backgroundColor: "#0066FF", + borderBottomRightRadius: 4, + }, + assistantBubble: { + backgroundColor: "rgba(58, 58, 60, 0.8)", + borderBottomLeftRadius: 4, + }, + chatMessageText: { + fontSize: 15, + lineHeight: 20, + fontFamily: "System", + fontWeight: "400", + }, + userText: { + color: "#FFFFFF", + }, + assistantText: { + color: "#FFFFFF", + }, + toolMessageContainer: { + marginVertical: 6, alignItems: "center", - paddingHorizontal: 24, - paddingVertical: 16, - backgroundColor: "rgba(244, 67, 54, 0.1)", - borderRadius: 16, - marginTop: 32, + }, + toolCallBubble: { + backgroundColor: "rgba(0, 102, 255, 0.15)", + borderWidth: 1, + borderColor: "rgba(0, 102, 255, 0.3)", + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, maxWidth: "85%", }, - errorText: { - color: "#FF6B6B", - fontSize: 13, - fontWeight: "400", - textAlign: "center", - marginBottom: 12, + toolOutputBubble: { + backgroundColor: "rgba(52, 199, 89, 0.15)", + borderWidth: 1, + borderColor: "rgba(52, 199, 89, 0.3)", + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + maxWidth: "85%", + }, + toolHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 6, + gap: 6, + }, + toolHeaderText: { + fontSize: 11, + fontWeight: "600", + color: "rgba(255, 255, 255, 0.7)", + textTransform: "uppercase", fontFamily: "System", }, - retryText: { - color: "#FFFFFF", + toolName: { fontSize: 13, fontWeight: "600", + color: "#FFFFFF", + marginBottom: 4, fontFamily: "System", }, - widgetArea: { - height: 120, - backgroundColor: "rgba(28, 28, 30, 0.95)", - marginHorizontal: 16, - marginBottom: 16, - borderRadius: 16, - padding: 16, - justifyContent: "flex-start", + toolArgs: { + fontSize: 12, + color: "rgba(255, 255, 255, 0.8)", + fontFamily: "System", }, - widgetTitle: { + toolOutput: { fontSize: 12, - fontWeight: "600", - color: "rgba(255, 255, 255, 0.6)", - marginBottom: 8, + color: "rgba(255, 255, 255, 0.8)", fontFamily: "System", - textTransform: "uppercase", - letterSpacing: 0.5, }, - widgetText: { - fontSize: 14, + errorContainer: { + backgroundColor: "rgba(244, 67, 54, 0.15)", + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + errorText: { + color: "#FF6B6B", + fontSize: 13, fontWeight: "400", - color: "#FFFFFF", + textAlign: "center", fontFamily: "System", - lineHeight: 20, }, controlBar: { flexDirection: "row", @@ -806,28 +752,6 @@ const styles = StyleSheet.create({ }, }), }, - mutedOverlay: { - position: "absolute", - width: 240, - height: 240, - borderRadius: 120, - backgroundColor: "rgba(0, 0, 0, 0.85)", - justifyContent: "center", - alignItems: "center", - zIndex: 10, - }, - mutedIconContainer: { - alignItems: "center", - justifyContent: "center", - }, - mutedText: { - fontSize: 12, - fontWeight: "600", - color: "#FF3B30", - marginTop: 8, - textAlign: "center", - fontFamily: "System", - }, }); export default VoiceChatModal; diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 0afd723..d89e0b0 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -38,6 +38,7 @@ export interface VoiceTranscript { */ export interface ActiveTool { name: string; + args: string; status: 'running' | 'complete'; output?: string; } @@ -247,11 +248,11 @@ export function useVoiceChat() { setTranscripts(prev => [...prev, { role, content }]); }, - onToolStart: (toolName: string) => { - setActiveTools(prev => [...prev, { name: toolName, status: 'running' }]); + onToolCall: (toolName: string, args: string) => { + setActiveTools(prev => [...prev, { name: toolName, args, status: 'running' }]); }, - onToolEnd: (toolName: string, output: string) => { + onToolOutput: (toolName: string, output: string) => { setActiveTools(prev => prev.map(t => t.name === toolName && t.status === 'running' ? { ...t, status: 'complete' as const, output } diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index d50f272..9d37968 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -53,14 +53,14 @@ export interface VoiceTranscriptResponse { content: string; } -export interface VoiceToolStartResponse { - type: 'tool_start'; +export interface VoiceToolCallResponse { + type: 'tool_call'; tool_name: string; - arguments: string; + tool_args: string; } -export interface VoiceToolEndResponse { - type: 'tool_end'; +export interface VoiceToolOutputResponse { + type: 'tool_output'; tool_name: string; output: string; } @@ -78,8 +78,8 @@ export type VoiceServerMessage = | VoiceStatusResponse | VoiceAudioResponse | VoiceTranscriptResponse - | VoiceToolStartResponse - | VoiceToolEndResponse + | VoiceToolCallResponse + | VoiceToolOutputResponse | VoiceErrorResponse | VoiceDoneResponse; @@ -102,8 +102,8 @@ export interface VoiceChatCallbacks { onStatus?: (phase: VoiceServerPhase, message: string) => void; onAudioChunk?: (audioData: string, chunkIndex: number) => void; onTranscript?: (role: 'user' | 'assistant', content: string) => void; - onToolStart?: (toolName: string, args: string) => void; - onToolEnd?: (toolName: string, output: string) => void; + onToolCall?: (toolName: string, args: string) => void; + onToolOutput?: (toolName: string, output: string) => void; onError?: (error: string) => void; onConnectionOpen?: () => void; onConnectionClose?: () => void; @@ -277,17 +277,17 @@ export class VoiceBotWebSocket { this.handleTranscriptResponse(response as VoiceTranscriptResponse); break; - case 'tool_start': - this.callbacks.onToolStart?.( - (response as VoiceToolStartResponse).tool_name, - (response as VoiceToolStartResponse).arguments + case 'tool_call': + this.callbacks.onToolCall?.( + (response as VoiceToolCallResponse).tool_name, + (response as VoiceToolCallResponse).tool_args ); break; - case 'tool_end': - this.callbacks.onToolEnd?.( - (response as VoiceToolEndResponse).tool_name, - (response as VoiceToolEndResponse).output + case 'tool_output': + this.callbacks.onToolOutput?.( + (response as VoiceToolOutputResponse).tool_name, + (response as VoiceToolOutputResponse).output ); break; @@ -441,7 +441,7 @@ export class VoiceBotWebSocket { private validateResponse(response: any): response is VoiceServerMessage { if (!response || typeof response !== 'object') return false; - const validTypes = ['status', 'audio', 'transcript', 'tool_start', 'tool_end', 'error', 'done']; + const validTypes = ['status', 'audio', 'transcript', 'tool_call', 'tool_output', 'error', 'done']; if (!validTypes.includes(response.type)) return false; // Verifica lunghezza messaggi From c7379af9bce85435d97ca1a605db767edcca05a3 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Wed, 4 Feb 2026 16:57:50 +0100 Subject: [PATCH 13/29] refactor: improve voice chat message handling with new Message and ToolWidget formats, and add collapsible tools section --- src/components/BotChat/VoiceChatModal.tsx | 486 +++++++++++----------- 1 file changed, 236 insertions(+), 250 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 008531d..eaa0dbe 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -14,19 +14,11 @@ import { FlatList } from "react-native"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; -import { LinearGradient } from 'expo-linear-gradient'; import { useVoiceChat } from '../../hooks/useVoiceChat'; +import MessageBubble from './MessageBubble'; +import WidgetBubble from './widgets/WidgetBubble'; +import { Message, ToolWidget } from './types'; -// Tipi per i messaggi della voice chat -type VoiceChatMessageType = - | { type: 'transcript'; role: 'user' | 'assistant'; content: string; timestamp: Date } - | { type: 'tool_call'; toolName: string; args: string; timestamp: Date } - | { type: 'tool_output'; toolName: string; output: string; timestamp: Date }; - -interface VoiceChatMessage { - id: string; - data: VoiceChatMessageType; -} export interface VoiceChatModalProps { visible: boolean; @@ -37,6 +29,37 @@ export interface VoiceChatModalProps { const { height } = Dimensions.get("window"); +// Componente per il rendering di un singolo tool widget (memoizzato per evitare re-render) +const ToolWidgetCard = React.memo<{ widget: ToolWidget }>(({ widget }) => { + const getStatusIcon = () => { + if (widget.status === 'loading') return 'hourglass-empty'; + if (widget.status === 'success') return 'check-circle'; + return 'error'; + }; + + const getStatusColor = () => { + if (widget.status === 'loading') return '#666666'; + if (widget.status === 'success') return '#34C759'; + return '#FF3B30'; + }; + + return ( + + + + + {widget.toolName || 'Tool sconosciuto'} + + + + {widget.status === 'loading' && 'In esecuzione...'} + {widget.status === 'success' && 'Completato'} + {widget.status === 'error' && (widget.errorMessage || 'Errore')} + + + ); +}); + const VoiceChatModal: React.FC = ({ visible, onClose, @@ -67,56 +90,62 @@ const VoiceChatModal: React.FC = ({ const liveDotOpacity = useRef(new Animated.Value(1)).current; const flatListRef = useRef(null); - // Combina trascrizioni e tool calls/outputs in un'unica lista di messaggi - const chatMessages = useMemo(() => { - const messages: VoiceChatMessage[] = []; - let messageIndex = 0; - - // Aggiungi trascrizioni - transcripts.forEach((transcript, idx) => { - messages.push({ - id: `transcript-${idx}`, - data: { - type: 'transcript', - role: transcript.role, - content: transcript.content, - timestamp: new Date() + // Stato per la sezione tools collapsabile + const [toolsExpanded, setToolsExpanded] = useState(true); + const toolsHeight = useRef(new Animated.Value(1)).current; + + // Converte le trascrizioni al formato Message per MessageBubble + const chatMessages = useMemo(() => { + return transcripts.map((transcript, idx) => ({ + id: `transcript-${idx}`, + text: transcript.content, + sender: transcript.role === 'user' ? 'user' : 'bot', + start_time: new Date(), + isStreaming: false, + isComplete: true, + })); + }, [transcripts]); + + // Converte i tools attivi al formato ToolWidget + const toolWidgets = useMemo(() => { + return activeTools.map((tool, idx) => { + let parsedOutput = undefined; + if (tool.output) { + try { + parsedOutput = JSON.parse(tool.output); + } catch (e) { + parsedOutput = { message: tool.output }; } - }); - messageIndex++; - }); + } - // Aggiungi tool calls e outputs - activeTools.forEach((tool, idx) => { - // Tool call - messages.push({ - id: `tool-call-${idx}`, - data: { - type: 'tool_call', - toolName: tool.name, - args: tool.args, - timestamp: new Date() - } - }); - messageIndex++; - - // Tool output (se presente) - if (tool.status === 'complete' && tool.output) { - messages.push({ - id: `tool-output-${idx}`, - data: { - type: 'tool_output', - toolName: tool.name, - output: tool.output, - timestamp: new Date() - } - }); - messageIndex++; + // Converti args in stringa JSON se necessario + let toolArgsString = tool.args; + if (typeof tool.args === 'object') { + toolArgsString = JSON.stringify(tool.args); } - }); - return messages; - }, [transcripts, activeTools]); + return { + id: `tool-${tool.name}-${idx}`, + toolName: tool.name, + status: tool.status === 'complete' ? 'success' : tool.status === 'running' ? 'loading' : 'error', + itemIndex: idx, + toolArgs: toolArgsString, + toolOutput: parsedOutput, + errorMessage: tool.status === 'error' ? 'Errore durante l\'esecuzione' : undefined, + }; + }); + }, [activeTools]); + + // Animazione per espandere/collassare la sezione tools + const toggleToolsSection = () => { + const toValue = toolsExpanded ? 0 : 1; + Animated.timing(toolsHeight, { + toValue, + duration: 300, + useNativeDriver: false, + }).start(); + setToolsExpanded(!toolsExpanded); + }; // Auto-scroll quando arrivano nuovi messaggi useEffect(() => { @@ -266,63 +295,47 @@ const VoiceChatModal: React.FC = ({ } }; - // Render di un singolo messaggio della chat - const renderChatMessage = ({ item }: { item: VoiceChatMessage }) => { - const { data } = item; - - if (data.type === 'transcript') { - const isUser = data.role === 'user'; - return ( - - - - {data.content} - - - - ); - } + // Render di un singolo messaggio usando MessageBubble + const renderChatMessage = ({ item }: { item: Message }) => { + return ; + }; - if (data.type === 'tool_call') { - return ( - - - - - Esecuzione tool - - {data.toolName} - {data.args} - - - ); - } + // Render della sezione tools collapsabile + const renderToolsSection = () => { + if (toolWidgets.length === 0) return null; - if (data.type === 'tool_output') { - return ( - - - - - Risultato - - {data.toolName} - {data.output} - - - ); - } + const animatedHeight = toolsHeight.interpolate({ + inputRange: [0, 1], + outputRange: [0, 300], // Altezza massima della sezione + }); - return null; + return ( + + + + + + Azioni in corso ({toolWidgets.length}) + + + + + + + {toolWidgets.map((widget) => ( + + ))} + + + ); }; return ( @@ -333,7 +346,7 @@ const VoiceChatModal: React.FC = ({ statusBarTranslucent={true} onRequestClose={handleClose} > - + = ({ )} + {/* Status Badge */} + + {renderStateIndicator()} + {isMuted && isConnected && ( + + + Muto + + )} + + {/* Close Button */} = ({ accessibilityRole="button" accessibilityLabel="Chiudi chat vocale" > - + + {/* Sezione Tools Collapsabile */} + {renderToolsSection()} + {/* Contenuto principale - Chat */} - {/* Messaggio di stato in alto */} - - {renderStateIndicator()} - {isMuted && isConnected && ( - - - Muto - - )} - - {/* Lista messaggi */} {chatMessages.length > 0 ? ( = ({ {state === 'connecting' || state === 'authenticating' || state === 'setting_up' ? ( <> - + Connessione in corso... ) : state === 'error' ? ( @@ -412,7 +428,7 @@ const VoiceChatModal: React.FC = ({ ) : ( <> - + Inizia a parlare La conversazione apparirΓ  qui @@ -454,20 +470,9 @@ const VoiceChatModal: React.FC = ({ - - {/* End Call Button */} - - - @@ -477,21 +482,30 @@ const VoiceChatModal: React.FC = ({ const styles = StyleSheet.create({ overlay: { flex: 1, - backgroundColor: "#000000", + backgroundColor: "#FFFFFF", justifyContent: "space-between", }, header: { paddingTop: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 44, paddingHorizontal: 20, - paddingBottom: 16, + paddingBottom: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center", + borderBottomWidth: 1, + borderBottomColor: "#E1E5E9", + }, + headerCenter: { + flex: 1, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 8, }, liveIndicator: { flexDirection: "row", alignItems: "center", - backgroundColor: "rgba(255, 255, 255, 0.1)", + backgroundColor: "rgba(52, 199, 89, 0.1)", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, @@ -506,39 +520,34 @@ const styles = StyleSheet.create({ liveText: { fontSize: 13, fontWeight: "600", - color: "#FFFFFF", + color: "#34C759", fontFamily: "System", }, closeButton: { width: 44, height: 44, borderRadius: 22, - backgroundColor: "rgba(58, 58, 60, 0.6)", + backgroundColor: "#F8F9FA", justifyContent: "center", alignItems: "center", + borderWidth: 1, + borderColor: "#E1E5E9", }, content: { flex: 1, paddingHorizontal: 0, - }, - statusBar: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingHorizontal: 20, - paddingVertical: 12, - backgroundColor: "rgba(0, 0, 0, 0.3)", + backgroundColor: "#FFFFFF", }, subtleText: { fontSize: 13, - fontWeight: "300", - color: "rgba(255, 255, 255, 0.7)", + fontWeight: "400", + color: "#666666", fontFamily: "System", }, mutedBadge: { flexDirection: "row", alignItems: "center", - backgroundColor: "rgba(255, 59, 48, 0.2)", + backgroundColor: "rgba(255, 59, 48, 0.1)", paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, @@ -551,7 +560,6 @@ const styles = StyleSheet.create({ fontFamily: "System", }, chatList: { - paddingHorizontal: 16, paddingVertical: 16, paddingBottom: 32, }, @@ -564,7 +572,7 @@ const styles = StyleSheet.create({ emptyChatText: { fontSize: 18, fontWeight: "500", - color: "#FFFFFF", + color: "#000000", textAlign: "center", marginBottom: 8, fontFamily: "System", @@ -572,7 +580,7 @@ const styles = StyleSheet.create({ emptyChatSubtext: { fontSize: 14, fontWeight: "300", - color: "rgba(255, 255, 255, 0.6)", + color: "#666666", textAlign: "center", fontFamily: "System", }, @@ -580,7 +588,7 @@ const styles = StyleSheet.create({ marginTop: 16, paddingHorizontal: 24, paddingVertical: 12, - backgroundColor: "#0066FF", + backgroundColor: "#000000", borderRadius: 20, }, retryButtonText: { @@ -589,108 +597,79 @@ const styles = StyleSheet.create({ color: "#FFFFFF", fontFamily: "System", }, - chatMessageContainer: { - marginVertical: 6, - }, - userMessageContainer: { - alignItems: "flex-end", - }, - assistantMessageContainer: { - alignItems: "flex-start", - }, - chatMessageBubble: { - maxWidth: "80%", + errorContainer: { + backgroundColor: "rgba(255, 59, 48, 0.1)", + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 8, paddingHorizontal: 16, paddingVertical: 12, - borderRadius: 18, - }, - userBubble: { - backgroundColor: "#0066FF", - borderBottomRightRadius: 4, - }, - assistantBubble: { - backgroundColor: "rgba(58, 58, 60, 0.8)", - borderBottomLeftRadius: 4, + borderWidth: 1, + borderColor: "rgba(255, 59, 48, 0.2)", }, - chatMessageText: { - fontSize: 15, - lineHeight: 20, - fontFamily: "System", + errorText: { + color: "#FF3B30", + fontSize: 13, fontWeight: "400", + textAlign: "center", + fontFamily: "System", }, - userText: { - color: "#FFFFFF", - }, - assistantText: { - color: "#FFFFFF", + // Tools Section + toolsSection: { + backgroundColor: "#F8F9FA", + borderBottomWidth: 1, + borderBottomColor: "#E1E5E9", }, - toolMessageContainer: { - marginVertical: 6, + toolsSectionHeader: { + flexDirection: "row", + justifyContent: "space-between", alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 12, }, - toolCallBubble: { - backgroundColor: "rgba(0, 102, 255, 0.15)", - borderWidth: 1, - borderColor: "rgba(0, 102, 255, 0.3)", - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 12, - maxWidth: "85%", - }, - toolOutputBubble: { - backgroundColor: "rgba(52, 199, 89, 0.15)", - borderWidth: 1, - borderColor: "rgba(52, 199, 89, 0.3)", - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 12, - maxWidth: "85%", - }, - toolHeader: { + toolsSectionHeaderLeft: { flexDirection: "row", alignItems: "center", - marginBottom: 6, - gap: 6, + gap: 8, }, - toolHeaderText: { - fontSize: 11, + toolsSectionTitle: { + fontSize: 14, fontWeight: "600", - color: "rgba(255, 255, 255, 0.7)", - textTransform: "uppercase", + color: "#000000", fontFamily: "System", }, - toolName: { - fontSize: 13, - fontWeight: "600", - color: "#FFFFFF", + toolsSectionContent: { + paddingHorizontal: 20, + paddingBottom: 12, + overflow: "hidden", + }, + toolWidgetCard: { + backgroundColor: "#FFFFFF", + borderRadius: 12, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: "#E1E5E9", + }, + toolWidgetHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, marginBottom: 4, - fontFamily: "System", }, - toolArgs: { - fontSize: 12, - color: "rgba(255, 255, 255, 0.8)", + toolWidgetName: { + fontSize: 14, + fontWeight: "600", + color: "#000000", fontFamily: "System", + flex: 1, }, - toolOutput: { + toolWidgetStatus: { fontSize: 12, - color: "rgba(255, 255, 255, 0.8)", - fontFamily: "System", - }, - errorContainer: { - backgroundColor: "rgba(244, 67, 54, 0.15)", - borderRadius: 12, - marginHorizontal: 16, - marginVertical: 8, - paddingHorizontal: 16, - paddingVertical: 12, - }, - errorText: { - color: "#FF6B6B", - fontSize: 13, - fontWeight: "400", - textAlign: "center", + color: "#666666", fontFamily: "System", }, + // Control Bar controlBar: { flexDirection: "row", justifyContent: "center", @@ -699,32 +678,38 @@ const styles = StyleSheet.create({ paddingHorizontal: 32, paddingVertical: 24, paddingBottom: 40, - backgroundColor: "#000000", + backgroundColor: "#FFFFFF", + borderTopWidth: 1, + borderTopColor: "#E1E5E9", }, controlButton: { width: 56, height: 56, borderRadius: 28, - backgroundColor: "rgba(58, 58, 60, 0.8)", + backgroundColor: "#F8F9FA", justifyContent: "center", alignItems: "center", + borderWidth: 1, + borderColor: "#E1E5E9", }, controlButtonDisabled: { - backgroundColor: "rgba(58, 58, 60, 0.4)", + backgroundColor: "#F8F9FA", + opacity: 0.5, }, controlButtonActive: { - backgroundColor: "rgba(58, 58, 60, 1)", + backgroundColor: "#E1E5E9", }, controlButtonPrimary: { width: 64, height: 64, borderRadius: 32, - backgroundColor: "#0066FF", + backgroundColor: "#000000", + borderWidth: 0, ...Platform.select({ ios: { - shadowColor: "#0066FF", + shadowColor: "#000000", shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.4, + shadowOpacity: 0.3, shadowRadius: 8, }, android: { @@ -733,16 +718,17 @@ const styles = StyleSheet.create({ }), }, controlButtonRecording: { - backgroundColor: "#00CCFF", + backgroundColor: "#000000", }, controlButtonMuted: { - backgroundColor: "#FF3B30", + backgroundColor: "#E1E5E9", }, controlButtonEnd: { - backgroundColor: "#FF3B30", + backgroundColor: "#000000", + borderWidth: 0, ...Platform.select({ ios: { - shadowColor: "#FF3B30", + shadowColor: "#000000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, From 7f46687a30630ed5ffb9a0a1cfabfdb49f797042 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Wed, 4 Feb 2026 17:11:44 +0100 Subject: [PATCH 14/29] fix: resolved the icon in the entry for open the voice chat --- src/navigation/screens/Home.tsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 1747079..becf11c 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -195,6 +195,8 @@ const HomeScreen = () => { duration: 200, useNativeDriver: false, }).start(); + + console.log('[HOME] Mic button animation state:', { isInputFocused, targetValue: isInputFocused ? 0 : 1 }); }, [isInputFocused, micButtonAnim]); // Effetto per gestire la visualizzazione della tastiera @@ -216,6 +218,10 @@ const HomeScreen = () => { const keyboardDidHideListener = Keyboard.addListener( "keyboardDidHide", () => { + // Forza il reset del focus quando la tastiera si nasconde + console.log('[HOME] Keyboard hidden, resetting input focus state'); + setIsInputFocused(false); + if (chatStarted) { // Riporta l'input in posizione normale Animated.timing(inputBottomPosition, { @@ -811,8 +817,14 @@ const HomeScreen = () => { returnKeyType="send" blurOnSubmit={true} editable={!isLoading} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} + onFocus={() => { + console.log('[HOME] TextInput focused (under greeting)'); + setIsInputFocused(true); + }} + onBlur={() => { + console.log('[HOME] TextInput blurred (under greeting)'); + setIsInputFocused(false); + }} /> { returnKeyType="send" blurOnSubmit={true} editable={!isLoading} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} + onFocus={() => { + console.log('[HOME] TextInput focused (chat started)'); + setIsInputFocused(true); + }} + onBlur={() => { + console.log('[HOME] TextInput blurred (chat started)'); + setIsInputFocused(false); + }} /> Date: Thu, 5 Feb 2026 11:05:48 +0100 Subject: [PATCH 15/29] add: creata la versione alpha della visualizzazione dei widget nella chat vocale --- src/components/BotChat/MessageBubble.tsx | 85 +++++--- src/components/BotChat/VoiceChatModal.tsx | 188 ++++------------- src/components/BotChat/types.ts | 1 + .../BotChat/widgets/ErrorWidgetCard.tsx | 69 +++++++ .../BotChat/widgets/InlineCategoryList.tsx | 158 ++++++++++++++ .../BotChat/widgets/InlineTaskPreview.tsx | 143 +++++++++++++ .../widgets/InlineVisualizationWidget.tsx | 62 ++++++ .../BotChat/widgets/LoadingSkeletonCard.tsx | 192 ++++++++++++++++++ 8 files changed, 717 insertions(+), 181 deletions(-) create mode 100644 src/components/BotChat/widgets/ErrorWidgetCard.tsx create mode 100644 src/components/BotChat/widgets/InlineCategoryList.tsx create mode 100644 src/components/BotChat/widgets/InlineTaskPreview.tsx create mode 100644 src/components/BotChat/widgets/InlineVisualizationWidget.tsx create mode 100644 src/components/BotChat/widgets/LoadingSkeletonCard.tsx diff --git a/src/components/BotChat/MessageBubble.tsx b/src/components/BotChat/MessageBubble.tsx index c6f9aad..811bd21 100644 --- a/src/components/BotChat/MessageBubble.tsx +++ b/src/components/BotChat/MessageBubble.tsx @@ -5,6 +5,7 @@ import TaskListBubble from './TaskListBubble'; // Nuovo componente card-based import TaskTableBubble from './TaskTableBubble'; // Mantieni per backward compatibility import Markdown from 'react-native-markdown-display'; // Supporto per Markdown import WidgetBubble from './widgets/WidgetBubble'; +import InlineVisualizationWidget from './widgets/InlineVisualizationWidget'; import VisualizationModal from './widgets/VisualizationModal'; import ItemDetailModal from './widgets/ItemDetailModal'; import TaskEditModal from '../Task/TaskEditModal'; @@ -13,7 +14,7 @@ import CategoryMenu from '../Category/CategoryMenu'; import { Task as TaskType } from '../../services/taskService'; import { updateTask, updateCategory, deleteCategory } from '../../services/taskService'; -const MessageBubble: React.FC = ({ message, style }) => { +const MessageBubble: React.FC = ({ message, style, isVoiceChat = false }) => { const isBot = message.sender === 'bot'; const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(20)).current; @@ -362,38 +363,62 @@ const MessageBubble: React.FC = ({ message, style }) => { {/* WIDGETS SOPRA AL MESSAGGIO (come richiesto dall'utente) */} {isBot && message.toolWidgets && message.toolWidgets.length > 0 && ( - {message.toolWidgets.map((widget) => ( - - ))} + {message.toolWidgets.map((widget) => { + console.log('[MessageBubble] Rendering widget:', { + id: widget.id, + toolName: widget.toolName, + isVoiceChat, + hasToolOutput: !!widget.toolOutput, + }); + + // In voice chat usa InlineVisualizationWidget + if (isVoiceChat) { + return ( + + ); + } + + // In text chat usa WidgetBubble (comportamento attuale) + return ( + + ); + })} )} - {/* BUBBLE DEL MESSAGGIO */} - - {renderMessageContent()} - {message.isStreaming && isBot && ( - - - - - - )} - {message.modelType && isBot && !message.isStreaming && ( - - {message.modelType === 'advanced' ? 'Modello avanzato' : 'Modello base'} - - )} - + {/* BUBBLE DEL MESSAGGIO - renderizza solo se c'Γ¨ testo */} + {message.text && message.text.trim() !== '' && ( + + {renderMessageContent()} + {message.isStreaming && isBot && ( + + + + + + )} + {message.modelType && isBot && !message.isStreaming && ( + + {message.modelType === 'advanced' ? 'Modello avanzato' : 'Modello base'} + + )} + + )} (({ widget }) => { - const getStatusIcon = () => { - if (widget.status === 'loading') return 'hourglass-empty'; - if (widget.status === 'success') return 'check-circle'; - return 'error'; - }; - - const getStatusColor = () => { - if (widget.status === 'loading') return '#666666'; - if (widget.status === 'success') return '#34C759'; - return '#FF3B30'; - }; - - return ( - - - - - {widget.toolName || 'Tool sconosciuto'} - - - - {widget.status === 'loading' && 'In esecuzione...'} - {widget.status === 'success' && 'Completato'} - {widget.status === 'error' && (widget.errorMessage || 'Errore')} - - - ); -}); - const VoiceChatModal: React.FC = ({ visible, onClose, @@ -78,7 +46,6 @@ const VoiceChatModal: React.FC = ({ isMuted, connect, disconnect, - stopPlayback, requestPermissions, mute, unmute, @@ -90,10 +57,6 @@ const VoiceChatModal: React.FC = ({ const liveDotOpacity = useRef(new Animated.Value(1)).current; const flatListRef = useRef(null); - // Stato per la sezione tools collapsabile - const [toolsExpanded, setToolsExpanded] = useState(true); - const toolsHeight = useRef(new Animated.Value(1)).current; - // Converte le trascrizioni al formato Message per MessageBubble const chatMessages = useMemo(() => { return transcripts.map((transcript, idx) => ({ @@ -108,12 +71,23 @@ const VoiceChatModal: React.FC = ({ // Converte i tools attivi al formato ToolWidget const toolWidgets = useMemo(() => { + console.log('[VoiceChatModal] Converting activeTools to toolWidgets:', activeTools); + return activeTools.map((tool, idx) => { + console.log('[VoiceChatModal] Processing tool:', { + name: tool.name, + status: tool.status, + hasOutput: !!tool.output, + outputRaw: tool.output, + }); + let parsedOutput = undefined; if (tool.output) { try { parsedOutput = JSON.parse(tool.output); + console.log('[VoiceChatModal] Parsed output for', tool.name, ':', parsedOutput); } catch (e) { + console.error('[VoiceChatModal] Error parsing tool output:', e); parsedOutput = { message: tool.output }; } } @@ -124,37 +98,44 @@ const VoiceChatModal: React.FC = ({ toolArgsString = JSON.stringify(tool.args); } - return { + const toolWidget = { id: `tool-${tool.name}-${idx}`, toolName: tool.name, - status: tool.status === 'complete' ? 'success' : tool.status === 'running' ? 'loading' : 'error', + status: tool.status === 'complete' ? 'success' : 'loading', itemIndex: idx, toolArgs: toolArgsString, toolOutput: parsedOutput, - errorMessage: tool.status === 'error' ? 'Errore durante l\'esecuzione' : undefined, + errorMessage: undefined, }; + + console.log('[VoiceChatModal] Created toolWidget:', toolWidget); + return toolWidget; }); }, [activeTools]); - // Animazione per espandere/collassare la sezione tools - const toggleToolsSection = () => { - const toValue = toolsExpanded ? 0 : 1; - Animated.timing(toolsHeight, { - toValue, - duration: 300, - useNativeDriver: false, - }).start(); - setToolsExpanded(!toolsExpanded); - }; + // Merge messaggi trascrizioni + tool widgets come messaggi inline + const allMessages = useMemo(() => { + const toolMessages: Message[] = toolWidgets.map((widget) => ({ + id: widget.id, + text: '', // Vuoto (widget-only message) + sender: 'bot' as const, + start_time: new Date(), + isStreaming: widget.status === 'loading', + isComplete: widget.status !== 'loading', + toolWidgets: [widget] // Array con singolo widget + })); + + return [...chatMessages, ...toolMessages]; + }, [chatMessages, toolWidgets]); // Auto-scroll quando arrivano nuovi messaggi useEffect(() => { - if (chatMessages.length > 0) { + if (allMessages.length > 0) { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); } - }, [chatMessages.length]); + }, [allMessages.length]); // Solo length, non toolWidgets array intero // Notifica trascrizioni assistant al parent useEffect(() => { @@ -297,45 +278,7 @@ const VoiceChatModal: React.FC = ({ // Render di un singolo messaggio usando MessageBubble const renderChatMessage = ({ item }: { item: Message }) => { - return ; - }; - - // Render della sezione tools collapsabile - const renderToolsSection = () => { - if (toolWidgets.length === 0) return null; - - const animatedHeight = toolsHeight.interpolate({ - inputRange: [0, 1], - outputRange: [0, 300], // Altezza massima della sezione - }); - - return ( - - - - - - Azioni in corso ({toolWidgets.length}) - - - - - - - {toolWidgets.map((widget) => ( - - ))} - - - ); + return ; }; return ( @@ -395,16 +338,13 @@ const VoiceChatModal: React.FC = ({ - {/* Sezione Tools Collapsabile */} - {renderToolsSection()} - {/* Contenuto principale - Chat */} - {/* Lista messaggi */} - {chatMessages.length > 0 ? ( + {/* Lista messaggi (include tool widgets inline) */} + {allMessages.length > 0 ? ( item.id} contentContainerStyle={styles.chatList} @@ -615,60 +555,6 @@ const styles = StyleSheet.create({ fontFamily: "System", }, // Tools Section - toolsSection: { - backgroundColor: "#F8F9FA", - borderBottomWidth: 1, - borderBottomColor: "#E1E5E9", - }, - toolsSectionHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingHorizontal: 20, - paddingVertical: 12, - }, - toolsSectionHeaderLeft: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - toolsSectionTitle: { - fontSize: 14, - fontWeight: "600", - color: "#000000", - fontFamily: "System", - }, - toolsSectionContent: { - paddingHorizontal: 20, - paddingBottom: 12, - overflow: "hidden", - }, - toolWidgetCard: { - backgroundColor: "#FFFFFF", - borderRadius: 12, - padding: 12, - marginBottom: 8, - borderWidth: 1, - borderColor: "#E1E5E9", - }, - toolWidgetHeader: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginBottom: 4, - }, - toolWidgetName: { - fontSize: 14, - fontWeight: "600", - color: "#000000", - fontFamily: "System", - flex: 1, - }, - toolWidgetStatus: { - fontSize: 12, - color: "#666666", - fontFamily: "System", - }, // Control Bar controlBar: { flexDirection: "row", diff --git a/src/components/BotChat/types.ts b/src/components/BotChat/types.ts index 86fb99f..b1b18ee 100644 --- a/src/components/BotChat/types.ts +++ b/src/components/BotChat/types.ts @@ -47,6 +47,7 @@ export interface ChatSession { export interface MessageBubbleProps { message: Message; style?: StyleProp; + isVoiceChat?: boolean; // Flag per distinguere voice chat da text chat } // Props per il componente ChatInput diff --git a/src/components/BotChat/widgets/ErrorWidgetCard.tsx b/src/components/BotChat/widgets/ErrorWidgetCard.tsx new file mode 100644 index 0000000..4480a5f --- /dev/null +++ b/src/components/BotChat/widgets/ErrorWidgetCard.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ToolWidget } from '../types'; + +interface ErrorWidgetCardProps { + widget: ToolWidget; +} + +/** + * Error card per tool widgets falliti in voice chat + * Mostra messaggio di errore con icona rossa + */ +const ErrorWidgetCard: React.FC = ({ widget }) => { + const errorMessage = widget.errorMessage || 'Errore durante l\'esecuzione'; + + // Determina il messaggio specifico in base al tool + let specificMessage = errorMessage; + if (widget.toolName === 'show_tasks_to_user') { + specificMessage = 'Impossibile recuperare le task'; + } else if (widget.toolName === 'show_categories_to_user') { + specificMessage = 'Impossibile recuperare le categorie'; + } + + return ( + + + + + + {specificMessage} + {widget.errorMessage && widget.errorMessage !== specificMessage && ( + {widget.errorMessage} + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFE5E5', + borderRadius: 12, + padding: 12, + marginVertical: 8, + borderWidth: 1, + borderColor: '#FFCCCC', + }, + iconContainer: { + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + errorTitle: { + fontSize: 14, + fontWeight: '600', + color: '#FF3B30', + marginBottom: 2, + }, + errorDetail: { + fontSize: 12, + color: '#CC0000', + }, +}); + +export default ErrorWidgetCard; diff --git a/src/components/BotChat/widgets/InlineCategoryList.tsx b/src/components/BotChat/widgets/InlineCategoryList.tsx new file mode 100644 index 0000000..739e875 --- /dev/null +++ b/src/components/BotChat/widgets/InlineCategoryList.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ToolWidget, CategoryListItem } from '../types'; + +interface InlineCategoryListProps { + widget: ToolWidget; + onCategoryPress?: (category: CategoryListItem) => void; +} + +/** + * Lista completa di categorie inline per voice chat + * Mostra tutte le categorie come card semplificate + */ +const InlineCategoryList: React.FC = ({ widget, onCategoryPress }) => { + console.log('[InlineCategoryList] Rendering with widget:', { + hasToolOutput: !!widget.toolOutput, + toolOutput: widget.toolOutput, + toolOutputKeys: widget.toolOutput ? Object.keys(widget.toolOutput) : [], + }); + + // Nessun output disponibile + if (!widget.toolOutput) { + console.log('[InlineCategoryList] No toolOutput, returning null'); + return null; + } + + // Parse doppio: se toolOutput.text esiste, Γ¨ una stringa JSON con i dati veri + let parsedData = widget.toolOutput; + if (widget.toolOutput.type === 'text' && widget.toolOutput.text) { + try { + parsedData = JSON.parse(widget.toolOutput.text); + console.log('[InlineCategoryList] Parsed text field, data:', parsedData); + } catch (e) { + console.error('[InlineCategoryList] Error parsing text field:', e); + } + } + + // Gestisci sia formato diretto che formato con type wrapper + let categories: CategoryListItem[] = []; + + if (parsedData.type === 'category_list' && parsedData.categories) { + // Formato con type wrapper (come text chat) + categories = parsedData.categories; + console.log('[InlineCategoryList] Using wrapped format, categories:', categories.length); + } else if (parsedData.categories) { + // Formato diretto + categories = parsedData.categories; + console.log('[InlineCategoryList] Using direct format, categories:', categories.length); + } else { + console.log('[InlineCategoryList] No categories found in output'); + } + + // Lista vuota + if (categories.length === 0) { + console.log('[InlineCategoryList] Empty categories list'); + return ( + + Nessuna categoria trovata + + ); + } + + console.log('[InlineCategoryList] Rendering', categories.length, 'categories'); + + return ( + + {categories.map((category) => { + const taskCount = category.taskCount || category.task_count || 0; + const categoryColor = category.color || '#666666'; + + return ( + onCategoryPress?.(category)} + activeOpacity={0.7} + > + {/* Color badge */} + + + {/* Category info */} + + + {category.name} + + + {taskCount} {taskCount === 1 ? 'task' : 'task'} + + + + {/* Shared badge */} + {category.isShared && ( + + + + )} + + {/* Arrow icon */} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 8, + marginVertical: 4, + }, + emptyContainer: { + backgroundColor: '#F5F5F5', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginVertical: 4, + }, + emptyText: { + fontSize: 14, + color: '#666666', + fontStyle: 'italic', + }, + categoryCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#E0E0E0', + gap: 12, + }, + colorBadge: { + width: 4, + height: 40, + borderRadius: 2, + }, + categoryInfo: { + flex: 1, + }, + categoryName: { + fontSize: 15, + fontWeight: '600', + color: '#000000', + marginBottom: 4, + }, + taskCount: { + fontSize: 13, + color: '#666666', + }, + sharedBadge: { + marginRight: 4, + }, +}); + +export default InlineCategoryList; diff --git a/src/components/BotChat/widgets/InlineTaskPreview.tsx b/src/components/BotChat/widgets/InlineTaskPreview.tsx new file mode 100644 index 0000000..b1072b5 --- /dev/null +++ b/src/components/BotChat/widgets/InlineTaskPreview.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { ToolWidget, TaskListItem } from '../types'; +import { Task } from '../../../services/taskService'; +import TaskCard from '../../Task/TaskCard'; + +interface InlineTaskPreviewProps { + widget: ToolWidget; + onTaskPress?: (task: Task) => void; +} + +/** + * Preview inline di max 3 task per voice chat + * Mostra task cards con testo "+ N altre task" se ce ne sono di piΓΉ + */ +const InlineTaskPreview: React.FC = ({ widget, onTaskPress }) => { + console.log('[InlineTaskPreview] Rendering with widget:', { + hasToolOutput: !!widget.toolOutput, + toolOutput: widget.toolOutput, + toolOutputKeys: widget.toolOutput ? Object.keys(widget.toolOutput) : [], + }); + + // Nessun output disponibile + if (!widget.toolOutput) { + console.log('[InlineTaskPreview] No toolOutput, returning null'); + return null; + } + + // Parse doppio: se toolOutput.text esiste, Γ¨ una stringa JSON con i dati veri + let parsedData = widget.toolOutput; + if (widget.toolOutput.type === 'text' && widget.toolOutput.text) { + try { + parsedData = JSON.parse(widget.toolOutput.text); + console.log('[InlineTaskPreview] Parsed text field, data:', parsedData); + } catch (e) { + console.error('[InlineTaskPreview] Error parsing text field:', e); + } + } + + // Gestisci sia formato diretto che formato con type wrapper + let tasks: TaskListItem[] = []; + + if (parsedData.type === 'task_list' && parsedData.tasks) { + // Formato con type wrapper (come text chat) + tasks = parsedData.tasks; + console.log('[InlineTaskPreview] Using wrapped format, tasks:', tasks.length); + } else if (parsedData.tasks) { + // Formato diretto + tasks = parsedData.tasks; + console.log('[InlineTaskPreview] Using direct format, tasks:', tasks.length); + } else { + console.log('[InlineTaskPreview] No tasks found in output'); + } + + // Lista vuota + if (tasks.length === 0) { + console.log('[InlineTaskPreview] Empty tasks list'); + return ( + + Nessuna task trovata + + ); + } + + console.log('[InlineTaskPreview] Rendering', tasks.length, 'tasks (max 3)'); + + // Converti TaskListItem β†’ Task per TaskCard + const convertTaskListItemToTask = (item: TaskListItem): Task => { + return { + id: item.id, + title: item.title, + description: '', // TaskListItem non ha description + status: item.status || 'In corso', + priority: item.priority || 'Media', + category_id: undefined, + category_name: item.category || item.category_name, + start_time: item.end_time || item.endTimeFormatted, + end_time: item.end_time || item.endTimeFormatted, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + user_id: 0, + }; + }; + + const previewTasks = tasks.slice(0, 3); + const remainingCount = tasks.length - 3; + + return ( + + {previewTasks.map((taskItem) => { + const task = convertTaskListItemToTask(taskItem); + return ( + + ); + })} + + {remainingCount > 0 && ( + + + + {remainingCount} {remainingCount === 1 ? 'altra task' : 'altre task'} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 8, + marginVertical: 4, + }, + emptyContainer: { + backgroundColor: '#F5F5F5', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginVertical: 4, + }, + emptyText: { + fontSize: 14, + color: '#666666', + fontStyle: 'italic', + }, + moreTasksContainer: { + backgroundColor: '#F0F0F0', + borderRadius: 8, + padding: 12, + alignItems: 'center', + marginTop: 4, + }, + moreTasksText: { + fontSize: 13, + color: '#666666', + fontWeight: '500', + }, +}); + +export default InlineTaskPreview; diff --git a/src/components/BotChat/widgets/InlineVisualizationWidget.tsx b/src/components/BotChat/widgets/InlineVisualizationWidget.tsx new file mode 100644 index 0000000..e5fcc42 --- /dev/null +++ b/src/components/BotChat/widgets/InlineVisualizationWidget.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { ToolWidget, TaskListItem, CategoryListItem } from '../types'; +import { Task } from '../../../services/taskService'; +import LoadingSkeletonCard from './LoadingSkeletonCard'; +import ErrorWidgetCard from './ErrorWidgetCard'; +import InlineTaskPreview from './InlineTaskPreview'; +import InlineCategoryList from './InlineCategoryList'; + +interface InlineVisualizationWidgetProps { + widget: ToolWidget; + onTaskPress?: (task: Task) => void; + onCategoryPress?: (category: CategoryListItem) => void; +} + +/** + * Router per widget inline in voice chat + * Decide quale componente renderizzare in base allo stato del widget + */ +const InlineVisualizationWidget: React.FC = ({ + widget, + onTaskPress, + onCategoryPress, +}) => { + // LOG: Debug struttura widget + console.log('[InlineVisualizationWidget] Widget received:', { + id: widget.id, + toolName: widget.toolName, + status: widget.status, + hasToolOutput: !!widget.toolOutput, + toolOutput: widget.toolOutput, + toolArgs: widget.toolArgs, + }); + + // Loading state + if (widget.status === 'loading' && !widget.toolOutput) { + console.log('[InlineVisualizationWidget] Rendering LoadingSkeletonCard'); + return ; + } + + // Error state + if (widget.status === 'error') { + console.log('[InlineVisualizationWidget] Rendering ErrorWidgetCard'); + return ; + } + + // Success state - routing per tipo di tool + if (widget.toolName === 'show_tasks_to_user') { + console.log('[InlineVisualizationWidget] Routing to InlineTaskPreview'); + return ; + } + + if (widget.toolName === 'show_categories_to_user') { + console.log('[InlineVisualizationWidget] Routing to InlineCategoryList'); + return ; + } + + // Tool non supportato per inline rendering + console.log('[InlineVisualizationWidget] Tool not supported:', widget.toolName); + return null; +}; + +export default InlineVisualizationWidget; diff --git a/src/components/BotChat/widgets/LoadingSkeletonCard.tsx b/src/components/BotChat/widgets/LoadingSkeletonCard.tsx new file mode 100644 index 0000000..56d389f --- /dev/null +++ b/src/components/BotChat/widgets/LoadingSkeletonCard.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Text, ActivityIndicator, Animated, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ToolWidget } from '../types'; + +interface LoadingSkeletonCardProps { + widget: ToolWidget; +} + +/** + * Loading skeleton card per tool widgets in voice chat + * Mostra animazioni pulse + shimmer mentre il tool Γ¨ in esecuzione + */ +const LoadingSkeletonCard: React.FC = ({ widget }) => { + const pulseAnim = useRef(new Animated.Value(0.3)).current; + const shimmerAnim = useRef(new Animated.Value(-1)).current; + + useEffect(() => { + // Animazione di pulsazione + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0.3, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + + // Animazione shimmer + const shimmerAnimation = Animated.loop( + Animated.timing(shimmerAnim, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }) + ); + + pulseAnimation.start(); + shimmerAnimation.start(); + + return () => { + pulseAnimation.stop(); + shimmerAnimation.stop(); + }; + }, [pulseAnim, shimmerAnim]); + + // Determina il tipo di contenuto in base al tool name + let loadingText = 'Caricamento dati...'; + let icon: keyof typeof Ionicons.glyphMap = 'list'; + let skeletonCount = 2; + + if (widget.toolName === 'show_tasks_to_user') { + loadingText = 'Recupero task dal server...'; + icon = 'calendar-outline'; + skeletonCount = 3; + } else if (widget.toolName === 'show_categories_to_user') { + loadingText = 'Recupero categorie dal server...'; + icon = 'folder-outline'; + skeletonCount = 3; + } + + const shimmerTranslate = shimmerAnim.interpolate({ + inputRange: [-1, 1], + outputRange: [-200, 200], + }); + + return ( + + {/* Header con icona e testo */} + + + + + + {loadingText} + + + + + {/* Skeleton cards */} + + {Array.from({ length: skeletonCount }).map((_, i) => ( + + + + + + + + + + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginVertical: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#F0F0F0', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + loadingText: { + fontSize: 14, + color: '#666666', + fontWeight: '500', + marginRight: 8, + }, + spinner: { + marginLeft: 4, + }, + skeletonContainer: { + gap: 8, + }, + skeletonCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#E0E0E0', + overflow: 'hidden', + }, + shimmerOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + skeletonContent: { + gap: 8, + }, + skeletonLine: { + height: 12, + borderRadius: 6, + backgroundColor: '#E0E0E0', + }, + skeletonTitle: { + width: '70%', + height: 16, + }, + skeletonSubtitle: { + width: '50%', + }, + skeletonMeta: { + flexDirection: 'row', + gap: 8, + marginTop: 4, + }, + skeletonBadge: { + width: 60, + height: 20, + borderRadius: 10, + backgroundColor: '#E0E0E0', + }, +}); + +export default LoadingSkeletonCard; From ff7dce473ec0b97b7eddc0dd85c8c5ef73af5476 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Thu, 5 Feb 2026 11:26:02 +0100 Subject: [PATCH 16/29] feat: migliroata la visualizzazione dei task risolvendo un errore di loop infinito --- src/components/BotChat/MessageBubble.tsx | 7 ---- src/components/BotChat/VoiceChatModal.tsx | 41 +++++++++---------- src/components/BotChat/types.ts | 5 ++- .../BotChat/widgets/ErrorWidgetCard.tsx | 4 +- .../BotChat/widgets/InlineCategoryList.tsx | 21 ++-------- .../BotChat/widgets/InlineTaskPreview.tsx | 19 +-------- .../widgets/InlineVisualizationWidget.tsx | 19 +-------- .../BotChat/widgets/LoadingSkeletonCard.tsx | 4 +- 8 files changed, 34 insertions(+), 86 deletions(-) diff --git a/src/components/BotChat/MessageBubble.tsx b/src/components/BotChat/MessageBubble.tsx index 811bd21..23d3e42 100644 --- a/src/components/BotChat/MessageBubble.tsx +++ b/src/components/BotChat/MessageBubble.tsx @@ -364,13 +364,6 @@ const MessageBubble: React.FC = ({ message, style, isVoiceCh {isBot && message.toolWidgets && message.toolWidgets.length > 0 && ( {message.toolWidgets.map((widget) => { - console.log('[MessageBubble] Rendering widget:', { - id: widget.id, - toolName: widget.toolName, - isVoiceChat, - hasToolOutput: !!widget.toolOutput, - }); - // In voice chat usa InlineVisualizationWidget if (isVoiceChat) { return ( diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 0806191..645ffb1 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState, useMemo } from "react"; +import React, { useRef, useEffect, useState, useMemo, useCallback } from "react"; import { View, Text, @@ -16,7 +16,7 @@ import { import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { useVoiceChat } from '../../hooks/useVoiceChat'; import MessageBubble from './MessageBubble'; -import { Message, ToolWidget } from './types'; +import { Message, ToolWidget, ToolOutputData } from './types'; export interface VoiceChatModalProps { @@ -71,21 +71,11 @@ const VoiceChatModal: React.FC = ({ // Converte i tools attivi al formato ToolWidget const toolWidgets = useMemo(() => { - console.log('[VoiceChatModal] Converting activeTools to toolWidgets:', activeTools); - return activeTools.map((tool, idx) => { - console.log('[VoiceChatModal] Processing tool:', { - name: tool.name, - status: tool.status, - hasOutput: !!tool.output, - outputRaw: tool.output, - }); - - let parsedOutput = undefined; + let parsedOutput: ToolOutputData | undefined = undefined; if (tool.output) { try { - parsedOutput = JSON.parse(tool.output); - console.log('[VoiceChatModal] Parsed output for', tool.name, ':', parsedOutput); + parsedOutput = JSON.parse(tool.output) as ToolOutputData; } catch (e) { console.error('[VoiceChatModal] Error parsing tool output:', e); parsedOutput = { message: tool.output }; @@ -98,35 +88,42 @@ const VoiceChatModal: React.FC = ({ toolArgsString = JSON.stringify(tool.args); } - const toolWidget = { + const toolWidget: ToolWidget = { id: `tool-${tool.name}-${idx}`, toolName: tool.name, - status: tool.status === 'complete' ? 'success' : 'loading', + status: (tool.status === 'complete' ? 'success' : 'loading') as 'success' | 'loading' | 'error', itemIndex: idx, toolArgs: toolArgsString, toolOutput: parsedOutput, errorMessage: undefined, }; - console.log('[VoiceChatModal] Created toolWidget:', toolWidget); return toolWidget; }); }, [activeTools]); + // Crea chiavi serializzate DENTRO useMemo per stabilitΓ  + const chatMessagesKey = useMemo(() => chatMessages.map(m => m.id).join(','), [chatMessages]); + const toolWidgetsKey = useMemo(() => toolWidgets.map(w => `${w.id}:${w.status}`).join(','), [toolWidgets]); + // Merge messaggi trascrizioni + tool widgets come messaggi inline const allMessages = useMemo(() => { - const toolMessages: Message[] = toolWidgets.map((widget) => ({ + + // Usa una data di riferimento stabile per i tool messages + const baseDate = new Date('2024-01-01'); + + const toolMessages: Message[] = toolWidgets.map((widget, idx) => ({ id: widget.id, text: '', // Vuoto (widget-only message) sender: 'bot' as const, - start_time: new Date(), + start_time: baseDate, // Data fissa per evitare loop! isStreaming: widget.status === 'loading', isComplete: widget.status !== 'loading', toolWidgets: [widget] // Array con singolo widget })); return [...chatMessages, ...toolMessages]; - }, [chatMessages, toolWidgets]); + }, [chatMessagesKey, toolWidgetsKey]); // Auto-scroll quando arrivano nuovi messaggi useEffect(() => { @@ -277,9 +274,9 @@ const VoiceChatModal: React.FC = ({ }; // Render di un singolo messaggio usando MessageBubble - const renderChatMessage = ({ item }: { item: Message }) => { + const renderChatMessage = useCallback(({ item }: { item: Message }) => { return ; - }; + }, []); return ( = ({ widget }) => { +const ErrorWidgetCard: React.FC = React.memo(({ widget }) => { const errorMessage = widget.errorMessage || 'Errore durante l\'esecuzione'; // Determina il messaggio specifico in base al tool @@ -35,7 +35,7 @@ const ErrorWidgetCard: React.FC = ({ widget }) => { ); -}; +}); const styles = StyleSheet.create({ container: { diff --git a/src/components/BotChat/widgets/InlineCategoryList.tsx b/src/components/BotChat/widgets/InlineCategoryList.tsx index 739e875..5fc4fb1 100644 --- a/src/components/BotChat/widgets/InlineCategoryList.tsx +++ b/src/components/BotChat/widgets/InlineCategoryList.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ToolWidget, CategoryListItem } from '../types'; @@ -12,16 +12,9 @@ interface InlineCategoryListProps { * Lista completa di categorie inline per voice chat * Mostra tutte le categorie come card semplificate */ -const InlineCategoryList: React.FC = ({ widget, onCategoryPress }) => { - console.log('[InlineCategoryList] Rendering with widget:', { - hasToolOutput: !!widget.toolOutput, - toolOutput: widget.toolOutput, - toolOutputKeys: widget.toolOutput ? Object.keys(widget.toolOutput) : [], - }); - +const InlineCategoryList: React.FC = React.memo(({ widget, onCategoryPress }) => { // Nessun output disponibile if (!widget.toolOutput) { - console.log('[InlineCategoryList] No toolOutput, returning null'); return null; } @@ -30,7 +23,6 @@ const InlineCategoryList: React.FC = ({ widget, onCateg if (widget.toolOutput.type === 'text' && widget.toolOutput.text) { try { parsedData = JSON.parse(widget.toolOutput.text); - console.log('[InlineCategoryList] Parsed text field, data:', parsedData); } catch (e) { console.error('[InlineCategoryList] Error parsing text field:', e); } @@ -42,18 +34,13 @@ const InlineCategoryList: React.FC = ({ widget, onCateg if (parsedData.type === 'category_list' && parsedData.categories) { // Formato con type wrapper (come text chat) categories = parsedData.categories; - console.log('[InlineCategoryList] Using wrapped format, categories:', categories.length); } else if (parsedData.categories) { // Formato diretto categories = parsedData.categories; - console.log('[InlineCategoryList] Using direct format, categories:', categories.length); - } else { - console.log('[InlineCategoryList] No categories found in output'); } // Lista vuota if (categories.length === 0) { - console.log('[InlineCategoryList] Empty categories list'); return ( Nessuna categoria trovata @@ -61,8 +48,6 @@ const InlineCategoryList: React.FC = ({ widget, onCateg ); } - console.log('[InlineCategoryList] Rendering', categories.length, 'categories'); - return ( {categories.map((category) => { @@ -103,7 +88,7 @@ const InlineCategoryList: React.FC = ({ widget, onCateg })} ); -}; +}); const styles = StyleSheet.create({ container: { diff --git a/src/components/BotChat/widgets/InlineTaskPreview.tsx b/src/components/BotChat/widgets/InlineTaskPreview.tsx index b1072b5..da2ccb3 100644 --- a/src/components/BotChat/widgets/InlineTaskPreview.tsx +++ b/src/components/BotChat/widgets/InlineTaskPreview.tsx @@ -13,16 +13,9 @@ interface InlineTaskPreviewProps { * Preview inline di max 3 task per voice chat * Mostra task cards con testo "+ N altre task" se ce ne sono di piΓΉ */ -const InlineTaskPreview: React.FC = ({ widget, onTaskPress }) => { - console.log('[InlineTaskPreview] Rendering with widget:', { - hasToolOutput: !!widget.toolOutput, - toolOutput: widget.toolOutput, - toolOutputKeys: widget.toolOutput ? Object.keys(widget.toolOutput) : [], - }); - +const InlineTaskPreview: React.FC = React.memo(({ widget, onTaskPress }) => { // Nessun output disponibile if (!widget.toolOutput) { - console.log('[InlineTaskPreview] No toolOutput, returning null'); return null; } @@ -31,7 +24,6 @@ const InlineTaskPreview: React.FC = ({ widget, onTaskPre if (widget.toolOutput.type === 'text' && widget.toolOutput.text) { try { parsedData = JSON.parse(widget.toolOutput.text); - console.log('[InlineTaskPreview] Parsed text field, data:', parsedData); } catch (e) { console.error('[InlineTaskPreview] Error parsing text field:', e); } @@ -43,18 +35,13 @@ const InlineTaskPreview: React.FC = ({ widget, onTaskPre if (parsedData.type === 'task_list' && parsedData.tasks) { // Formato con type wrapper (come text chat) tasks = parsedData.tasks; - console.log('[InlineTaskPreview] Using wrapped format, tasks:', tasks.length); } else if (parsedData.tasks) { // Formato diretto tasks = parsedData.tasks; - console.log('[InlineTaskPreview] Using direct format, tasks:', tasks.length); - } else { - console.log('[InlineTaskPreview] No tasks found in output'); } // Lista vuota if (tasks.length === 0) { - console.log('[InlineTaskPreview] Empty tasks list'); return ( Nessuna task trovata @@ -62,8 +49,6 @@ const InlineTaskPreview: React.FC = ({ widget, onTaskPre ); } - console.log('[InlineTaskPreview] Rendering', tasks.length, 'tasks (max 3)'); - // Converti TaskListItem β†’ Task per TaskCard const convertTaskListItemToTask = (item: TaskListItem): Task => { return { @@ -107,7 +92,7 @@ const InlineTaskPreview: React.FC = ({ widget, onTaskPre )} ); -}; +}); const styles = StyleSheet.create({ container: { diff --git a/src/components/BotChat/widgets/InlineVisualizationWidget.tsx b/src/components/BotChat/widgets/InlineVisualizationWidget.tsx index e5fcc42..dc76bca 100644 --- a/src/components/BotChat/widgets/InlineVisualizationWidget.tsx +++ b/src/components/BotChat/widgets/InlineVisualizationWidget.tsx @@ -16,47 +16,32 @@ interface InlineVisualizationWidgetProps { * Router per widget inline in voice chat * Decide quale componente renderizzare in base allo stato del widget */ -const InlineVisualizationWidget: React.FC = ({ +const InlineVisualizationWidget: React.FC = React.memo(({ widget, onTaskPress, onCategoryPress, }) => { - // LOG: Debug struttura widget - console.log('[InlineVisualizationWidget] Widget received:', { - id: widget.id, - toolName: widget.toolName, - status: widget.status, - hasToolOutput: !!widget.toolOutput, - toolOutput: widget.toolOutput, - toolArgs: widget.toolArgs, - }); - // Loading state if (widget.status === 'loading' && !widget.toolOutput) { - console.log('[InlineVisualizationWidget] Rendering LoadingSkeletonCard'); return ; } // Error state if (widget.status === 'error') { - console.log('[InlineVisualizationWidget] Rendering ErrorWidgetCard'); return ; } // Success state - routing per tipo di tool if (widget.toolName === 'show_tasks_to_user') { - console.log('[InlineVisualizationWidget] Routing to InlineTaskPreview'); return ; } if (widget.toolName === 'show_categories_to_user') { - console.log('[InlineVisualizationWidget] Routing to InlineCategoryList'); return ; } // Tool non supportato per inline rendering - console.log('[InlineVisualizationWidget] Tool not supported:', widget.toolName); return null; -}; +}); export default InlineVisualizationWidget; diff --git a/src/components/BotChat/widgets/LoadingSkeletonCard.tsx b/src/components/BotChat/widgets/LoadingSkeletonCard.tsx index 56d389f..4630119 100644 --- a/src/components/BotChat/widgets/LoadingSkeletonCard.tsx +++ b/src/components/BotChat/widgets/LoadingSkeletonCard.tsx @@ -11,7 +11,7 @@ interface LoadingSkeletonCardProps { * Loading skeleton card per tool widgets in voice chat * Mostra animazioni pulse + shimmer mentre il tool Γ¨ in esecuzione */ -const LoadingSkeletonCard: React.FC = ({ widget }) => { +const LoadingSkeletonCard: React.FC = React.memo(({ widget }) => { const pulseAnim = useRef(new Animated.Value(0.3)).current; const shimmerAnim = useRef(new Animated.Value(-1)).current; @@ -108,7 +108,7 @@ const LoadingSkeletonCard: React.FC = ({ widget }) => ); -}; +}); const styles = StyleSheet.create({ container: { From 8db0473608036e3e715b44af2396ec92926a4968 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 10:50:46 +0100 Subject: [PATCH 17/29] remove: removed the widget from the voice chat --- src/components/BotChat/VoiceChatModal.tsx | 204 +--------------------- src/hooks/useVoiceChat.ts | 136 ++++++++++++--- src/services/voiceBotService.ts | 6 + 3 files changed, 121 insertions(+), 225 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 645ffb1..4e1fed4 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState, useMemo, useCallback } from "react"; +import React, { useRef, useEffect } from "react"; import { View, Text, @@ -8,15 +8,11 @@ import { Animated, Dimensions, StatusBar, - ActivityIndicator, Alert, - Platform, - FlatList + Platform } from "react-native"; -import { Ionicons, MaterialIcons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import { useVoiceChat } from '../../hooks/useVoiceChat'; -import MessageBubble from './MessageBubble'; -import { Message, ToolWidget, ToolOutputData } from './types'; export interface VoiceChatModalProps { @@ -55,84 +51,6 @@ const VoiceChatModal: React.FC = ({ const slideIn = useRef(new Animated.Value(height)).current; const fadeIn = useRef(new Animated.Value(0)).current; const liveDotOpacity = useRef(new Animated.Value(1)).current; - const flatListRef = useRef(null); - - // Converte le trascrizioni al formato Message per MessageBubble - const chatMessages = useMemo(() => { - return transcripts.map((transcript, idx) => ({ - id: `transcript-${idx}`, - text: transcript.content, - sender: transcript.role === 'user' ? 'user' : 'bot', - start_time: new Date(), - isStreaming: false, - isComplete: true, - })); - }, [transcripts]); - - // Converte i tools attivi al formato ToolWidget - const toolWidgets = useMemo(() => { - return activeTools.map((tool, idx) => { - let parsedOutput: ToolOutputData | undefined = undefined; - if (tool.output) { - try { - parsedOutput = JSON.parse(tool.output) as ToolOutputData; - } catch (e) { - console.error('[VoiceChatModal] Error parsing tool output:', e); - parsedOutput = { message: tool.output }; - } - } - - // Converti args in stringa JSON se necessario - let toolArgsString = tool.args; - if (typeof tool.args === 'object') { - toolArgsString = JSON.stringify(tool.args); - } - - const toolWidget: ToolWidget = { - id: `tool-${tool.name}-${idx}`, - toolName: tool.name, - status: (tool.status === 'complete' ? 'success' : 'loading') as 'success' | 'loading' | 'error', - itemIndex: idx, - toolArgs: toolArgsString, - toolOutput: parsedOutput, - errorMessage: undefined, - }; - - return toolWidget; - }); - }, [activeTools]); - - // Crea chiavi serializzate DENTRO useMemo per stabilitΓ  - const chatMessagesKey = useMemo(() => chatMessages.map(m => m.id).join(','), [chatMessages]); - const toolWidgetsKey = useMemo(() => toolWidgets.map(w => `${w.id}:${w.status}`).join(','), [toolWidgets]); - - // Merge messaggi trascrizioni + tool widgets come messaggi inline - const allMessages = useMemo(() => { - - // Usa una data di riferimento stabile per i tool messages - const baseDate = new Date('2024-01-01'); - - const toolMessages: Message[] = toolWidgets.map((widget, idx) => ({ - id: widget.id, - text: '', // Vuoto (widget-only message) - sender: 'bot' as const, - start_time: baseDate, // Data fissa per evitare loop! - isStreaming: widget.status === 'loading', - isComplete: widget.status !== 'loading', - toolWidgets: [widget] // Array con singolo widget - })); - - return [...chatMessages, ...toolMessages]; - }, [chatMessagesKey, toolWidgetsKey]); - - // Auto-scroll quando arrivano nuovi messaggi - useEffect(() => { - if (allMessages.length > 0) { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); - } - }, [allMessages.length]); // Solo length, non toolWidgets array intero // Notifica trascrizioni assistant al parent useEffect(() => { @@ -241,11 +159,6 @@ const VoiceChatModal: React.FC = ({ await disconnect(); }; - const handleErrorDismiss = () => { - if (state === 'error') { - handleConnect(); - } - }; // Render dello stato const renderStateIndicator = () => { @@ -273,10 +186,6 @@ const VoiceChatModal: React.FC = ({ } }; - // Render di un singolo messaggio usando MessageBubble - const renderChatMessage = useCallback(({ item }: { item: Message }) => { - return ; - }, []); return ( = ({ - {/* Contenuto principale - Chat */} - - {/* Lista messaggi (include tool widgets inline) */} - {allMessages.length > 0 ? ( - item.id} - contentContainerStyle={styles.chatList} - showsVerticalScrollIndicator={false} - onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })} - /> - ) : ( - - {state === 'connecting' || state === 'authenticating' || state === 'setting_up' ? ( - <> - - Connessione in corso... - - ) : state === 'error' ? ( - <> - - Errore di connessione - - Riprova - - - ) : ( - <> - - Inizia a parlare - La conversazione apparirΓ  qui - - )} - - )} - - {/* Messaggio di errore */} - {error && ( - - {error} - - )} - - {/* Bottom Control Bar */} {/* Microphone Button - Primary */} @@ -470,11 +333,6 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: "#E1E5E9", }, - content: { - flex: 1, - paddingHorizontal: 0, - backgroundColor: "#FFFFFF", - }, subtleText: { fontSize: 13, fontWeight: "400", @@ -496,62 +354,6 @@ const styles = StyleSheet.create({ color: "#FF3B30", fontFamily: "System", }, - chatList: { - paddingVertical: 16, - paddingBottom: 32, - }, - emptyChat: { - flex: 1, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 32, - }, - emptyChatText: { - fontSize: 18, - fontWeight: "500", - color: "#000000", - textAlign: "center", - marginBottom: 8, - fontFamily: "System", - }, - emptyChatSubtext: { - fontSize: 14, - fontWeight: "300", - color: "#666666", - textAlign: "center", - fontFamily: "System", - }, - retryButton: { - marginTop: 16, - paddingHorizontal: 24, - paddingVertical: 12, - backgroundColor: "#000000", - borderRadius: 20, - }, - retryButtonText: { - fontSize: 15, - fontWeight: "600", - color: "#FFFFFF", - fontFamily: "System", - }, - errorContainer: { - backgroundColor: "rgba(255, 59, 48, 0.1)", - borderRadius: 12, - marginHorizontal: 16, - marginVertical: 8, - paddingHorizontal: 16, - paddingVertical: 12, - borderWidth: 1, - borderColor: "rgba(255, 59, 48, 0.2)", - }, - errorText: { - color: "#FF3B30", - fontSize: 13, - fontWeight: "400", - textAlign: "center", - fontFamily: "System", - }, - // Tools Section // Control Bar controlBar: { flexDirection: "row", diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index d89e0b0..c657563 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -70,6 +70,7 @@ export function useVoiceChat() { const shouldAutoStartRecordingRef = useRef(false); const agentEndedRef = useRef(true); // true = agent ha finito, possiamo registrare const isMutedRef = useRef(false); + const isManuallyMutedRef = useRef(false); // Distingue tra mute manuale e automatico /** * Verifica e richiede i permessi audio @@ -148,16 +149,50 @@ export function useVoiceChat() { }, onStatus: (phase: VoiceServerPhase, message: string) => { + console.log(`[useVoiceChat] onStatus: phase=${phase}, message=${message}`); setServerStatus({ phase, message }); switch (phase) { + case 'speech_started': + // Utente ha iniziato a parlare (VAD di OpenAI) + console.log('[useVoiceChat] 🎀 SPEECH_STARTED: Utente sta parlando'); + setState('recording'); + break; + + case 'speech_stopped': + // Utente ha finito di parlare (VAD di OpenAI) + // IMPORTANTE: Fermiamo il microfono QUI e non lo riattiveremo + // finchΓ© l'agent non ha completato TUTTO (elaborazione + riproduzione) + console.log('[useVoiceChat] πŸ›‘ SPEECH_STOPPED: Utente ha finito di parlare - auto-mute attivo'); + + // Auto-mute: ferma la registrazione + if (audioRecorderRef.current?.isCurrentlyRecording()) { + audioRecorderRef.current.stopRecording().catch(err => { + console.error('Errore fermando registrazione su speech_stopped:', err); + }); + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current); + recordingIntervalRef.current = null; + } + setRecordingDuration(0); + } + + // Aggiorna UI del mute (solo se non Γ¨ mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(true); + isMutedRef.current = true; + } + + setState('processing'); + break; + case 'agent_start': + console.log('[useVoiceChat] Agent iniziato - assicuro auto-mute'); setState('processing'); agentEndedRef.current = false; // Agent sta elaborando - // IMPORTANTE: Ferma la registrazione quando l'assistente inizia a rispondere - // per evitare che il microfono catturi l'audio della risposta (feedback loop) + + // Auto-mute (safety check): assicuriamoci che il microfono sia fermato if (audioRecorderRef.current?.isCurrentlyRecording()) { - console.log('Fermo registrazione - agent_start'); audioRecorderRef.current.stopRecording().catch(err => { console.error('Errore fermando registrazione su agent_start:', err); }); @@ -165,22 +200,45 @@ export function useVoiceChat() { clearInterval(recordingIntervalRef.current); recordingIntervalRef.current = null; } + setRecordingDuration(0); + } + + // Aggiorna UI del mute (solo se non Γ¨ mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(true); + isMutedRef.current = true; } break; case 'agent_end': // Agent ha finito di elaborare - console.log('Agent terminato'); + console.log('[useVoiceChat] Agent terminato'); agentEndedRef.current = true; - // Se ci sono chunk audio da riprodurre, aspettiamo audio_end - // Altrimenti, torniamo pronti - if (!audioPlayerRef.current || audioPlayerRef.current.getChunksCount() === 0) { + + // IMPORTANTE: Non riattivare il microfono se: + // 1. Ci sono chunk audio in coda da riprodurre + // 2. L'audio player sta ATTIVAMENTE riproducendo + const hasQueuedChunks = audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0; + const isPlaying = audioPlayerRef.current?.isCurrentlyPlaying(); + + if (!hasQueuedChunks && !isPlaying) { + console.log('[useVoiceChat] Nessun audio in riproduzione, auto-unmute'); setState('ready'); - if (!isMutedRef.current) { + + // Auto-unmute: riattiva microfono (solo se non mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(false); + isMutedRef.current = false; + + // Riavvia registrazione dopo un breve delay setTimeout(() => { - startRecording(); + if (audioRecorderRef.current && websocketRef.current?.isReady()) { + startRecording(); + } }, 300); } + } else { + console.log(`[useVoiceChat] Audio in corso (chunks: ${audioPlayerRef.current?.getChunksCount() || 0}, playing: ${isPlaying}), mantengo mute fino a fine riproduzione`); } break; @@ -188,31 +246,48 @@ export function useVoiceChat() { // Server ha finito di inviare chunk audio per questo segmento if (audioPlayerRef.current && audioPlayerRef.current.getChunksCount() > 0) { setState('speaking'); - console.log(`Avvio riproduzione audio (${audioPlayerRef.current.getChunksCount()} chunk)`); + console.log(`[useVoiceChat] Avvio riproduzione audio (${audioPlayerRef.current.getChunksCount()} chunk) - mantengo auto-mute`); audioPlayerRef.current.playPcm16Chunks(() => { - console.log('Riproduzione completata'); - // Riavvia la registrazione SOLO se l'agent ha finito e non Γ¨ mutato + console.log('[useVoiceChat] Riproduzione completata'); + // Riattiva il microfono SOLO se l'agent ha finito completamente if (agentEndedRef.current) { - console.log('Agent finito, riavvio registrazione'); + console.log('[useVoiceChat] Agent finito, auto-unmute e riavvio registrazione'); setState('ready'); - if (!isMutedRef.current) { + + // Auto-unmute: riattiva microfono (solo se non mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(false); + isMutedRef.current = false; + + // Riavvia registrazione dopo un breve delay setTimeout(() => { - startRecording(); + if (audioRecorderRef.current && websocketRef.current?.isReady()) { + startRecording(); + } }, 300); } } else { // Agent non ha ancora finito, torna in processing // e aspetta altri chunk audio o agent_end - console.log('Agent non ancora finito, attendo...'); + console.log('[useVoiceChat] Agent non ancora finito, attendo...'); setState('processing'); } }); } else if (agentEndedRef.current) { // Nessun audio da riprodurre e agent finito, torna pronto + console.log('[useVoiceChat] Nessun audio da riprodurre, auto-unmute'); setState('ready'); - if (!isMutedRef.current) { + + // Auto-unmute: riattiva microfono (solo se non mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(false); + isMutedRef.current = false; + + // Riavvia registrazione dopo un breve delay setTimeout(() => { - startRecording(); + if (audioRecorderRef.current && websocketRef.current?.isReady()) { + startRecording(); + } }, 300); } } @@ -220,17 +295,24 @@ export function useVoiceChat() { case 'interrupted': // Risposta interrotta dall'utente, torna pronto - console.log('Risposta interrotta'); + console.log('[useVoiceChat] Risposta interrotta, auto-unmute'); agentEndedRef.current = true; // Reset if (audioPlayerRef.current) { audioPlayerRef.current.stopPlayback(); audioPlayerRef.current.clearChunks(); } setState('ready'); - // L'utente ha interrotto, riavvia la registrazione se non mutato - if (!audioRecorderRef.current?.isCurrentlyRecording() && !isMutedRef.current) { + + // Auto-unmute: riattiva microfono (solo se non mutato manualmente) + if (!isManuallyMutedRef.current) { + setIsMuted(false); + isMutedRef.current = false; + + // Riavvia registrazione dopo un breve delay setTimeout(() => { - startRecording(); + if (audioRecorderRef.current && websocketRef.current?.isReady()) { + startRecording(); + } }, 200); } break; @@ -309,6 +391,7 @@ export function useVoiceChat() { setChunksReceived(0); shouldAutoStartRecordingRef.current = true; agentEndedRef.current = true; // Reset per nuova sessione + isManuallyMutedRef.current = false; // Reset mute manuale try { const connected = await websocketRef.current!.connect(); @@ -455,11 +538,13 @@ export function useVoiceChat() { }, []); /** - * Muta il microfono + * Muta il microfono (azione manuale dell'utente) */ const mute = useCallback(async (): Promise => { + console.log('[useVoiceChat] Mute manuale attivato'); setIsMuted(true); isMutedRef.current = true; + isManuallyMutedRef.current = true; // Marca come mute manuale // Ferma la registrazione se Γ¨ attiva if (audioRecorderRef.current?.isCurrentlyRecording()) { @@ -484,11 +569,13 @@ export function useVoiceChat() { }, [state]); /** - * Riattiva il microfono + * Riattiva il microfono (azione manuale dell'utente) */ const unmute = useCallback(async (): Promise => { + console.log('[useVoiceChat] Unmute manuale attivato'); setIsMuted(false); isMutedRef.current = false; + isManuallyMutedRef.current = false; // Rimuove il flag di mute manuale // Riavvia la registrazione se siamo in stato 'ready' if (state === 'ready' && websocketRef.current?.isReady()) { @@ -541,6 +628,7 @@ export function useVoiceChat() { setRecordingDuration(0); setIsMuted(false); // Reset mute state isMutedRef.current = false; + isManuallyMutedRef.current = false; // Reset mute manuale }, []); /** diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index 9d37968..63a2f60 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -30,6 +30,8 @@ export type VoiceClientMessage = export type VoiceServerPhase = | 'authenticated' | 'ready' + | 'speech_started' // Utente ha iniziato a parlare (VAD di OpenAI) + | 'speech_stopped' // Utente ha finito di parlare (VAD di OpenAI) | 'interrupted' | 'audio_end' | 'agent_start' @@ -308,6 +310,8 @@ export class VoiceBotWebSocket { const phase = response.phase; const message = response.message || ''; + console.log(`[VoiceBotWebSocket] handleStatusResponse: phase=${phase}, message=${message}`); + switch (phase) { case 'authenticated': console.log('Autenticazione WebSocket riuscita:', message); @@ -326,6 +330,8 @@ export class VoiceBotWebSocket { this.callbacks.onStatus?.(phase, message); break; + case 'speech_started': + case 'speech_stopped': case 'interrupted': case 'audio_end': case 'agent_start': From e9d6ae31153ef4be7c3d2967870d135324f0ad12 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 11:56:31 +0100 Subject: [PATCH 18/29] add: aggiunto il nuobvo calendario in stile google calendar che verra aggiunto alla chat vocale e che sostituira la pagina calendar normale --- .gitignore | 3 + src/components/Calendar20/AgendaView.tsx | 266 ++++++++++++ src/components/Calendar20/Calendar20View.tsx | 413 +++++++++++++++++++ src/components/Calendar20/DayView.tsx | 289 +++++++++++++ src/components/Calendar20/EventChip.tsx | 69 ++++ src/components/Calendar20/FABMenu.tsx | 177 ++++++++ src/components/Calendar20/MiniCalendar.tsx | 224 ++++++++++ src/components/Calendar20/MonthView.tsx | 243 +++++++++++ src/components/Calendar20/SearchOverlay.tsx | 215 ++++++++++ src/components/Calendar20/ThreeDayView.tsx | 299 ++++++++++++++ src/components/Calendar20/TimeBlock.tsx | 123 ++++++ src/components/Calendar20/TopBar.tsx | 127 ++++++ src/components/Calendar20/ViewDrawer.tsx | 228 ++++++++++ src/components/Calendar20/WeekView.tsx | 391 ++++++++++++++++++ src/components/Calendar20/categoryColors.ts | 84 ++++ src/components/Calendar20/types.ts | 42 ++ src/locales/en.json | 28 ++ src/locales/it.json | 28 ++ src/navigation/index.tsx | 10 + src/navigation/screens/Calendar20.tsx | 19 + 20 files changed, 3278 insertions(+) create mode 100644 src/components/Calendar20/AgendaView.tsx create mode 100644 src/components/Calendar20/Calendar20View.tsx create mode 100644 src/components/Calendar20/DayView.tsx create mode 100644 src/components/Calendar20/EventChip.tsx create mode 100644 src/components/Calendar20/FABMenu.tsx create mode 100644 src/components/Calendar20/MiniCalendar.tsx create mode 100644 src/components/Calendar20/MonthView.tsx create mode 100644 src/components/Calendar20/SearchOverlay.tsx create mode 100644 src/components/Calendar20/ThreeDayView.tsx create mode 100644 src/components/Calendar20/TimeBlock.tsx create mode 100644 src/components/Calendar20/TopBar.tsx create mode 100644 src/components/Calendar20/ViewDrawer.tsx create mode 100644 src/components/Calendar20/WeekView.tsx create mode 100644 src/components/Calendar20/categoryColors.ts create mode 100644 src/components/Calendar20/types.ts create mode 100644 src/navigation/screens/Calendar20.tsx diff --git a/.gitignore b/.gitignore index e170043..ff572ec 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ docs/ # Firebase secrets *firebase-adminsdk*.json + +# Auto Claude data directory +.auto-claude/ diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx new file mode 100644 index 0000000..cd3cfb3 --- /dev/null +++ b/src/components/Calendar20/AgendaView.tsx @@ -0,0 +1,266 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { + View, + Text, + SectionList, + TouchableOpacity, + StyleSheet, +} from 'react-native'; +import dayjs from 'dayjs'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarTask } from './types'; +import { useTranslation } from 'react-i18next'; + +const INITIAL_DAYS = 30; +const LOAD_MORE_DAYS = 30; + +interface AgendaViewProps { + currentDate: dayjs.Dayjs; + tasks: CalendarTask[]; + onDatePress: (date: dayjs.Dayjs) => void; + onTaskPress: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + onSwipeLeft: () => void; + onSwipeRight: () => void; +} + +interface AgendaSection { + title: string; + date: dayjs.Dayjs; + isToday: boolean; + data: CalendarTask[]; +} + +const AgendaView: React.FC = ({ + currentDate, + tasks, + onTaskPress, + onToggleComplete, +}) => { + const { t } = useTranslation(); + const [daysToShow, setDaysToShow] = useState(INITIAL_DAYS); + + const sections = useMemo((): AgendaSection[] => { + const today = dayjs(); + const result: AgendaSection[] = []; + + for (let i = 0; i < daysToShow; i++) { + const day = currentDate.add(i, 'day'); + const dayStr = day.format('YYYY-MM-DD'); + const dayTasks = tasks.filter(task => { + return ( + day.isSame(task.startDayjs, 'day') || + day.isSame(task.endDayjs, 'day') || + (day.isAfter(task.startDayjs, 'day') && day.isBefore(task.endDayjs, 'day')) + ); + }); + + // Sort by time + const sorted = [...dayTasks].sort((a, b) => { + if (a.isAllDay && !b.isAllDay) return -1; + if (!a.isAllDay && b.isAllDay) return 1; + return a.startDayjs.valueOf() - b.startDayjs.valueOf(); + }); + + const isToday = day.isSame(today, 'day'); + const title = isToday + ? `${t('calendar20.today')} - ${day.format('ddd, D MMM')}` + : day.format('ddd, D MMMM YYYY'); + + result.push({ + title, + date: day, + isToday, + data: sorted.length > 0 ? sorted : [{ _empty: true } as any], + }); + } + + return result; + }, [currentDate, tasks, daysToShow, t]); + + const loadMore = useCallback(() => { + setDaysToShow(prev => prev + LOAD_MORE_DAYS); + }, []); + + const renderItem = useCallback(({ item }: { item: CalendarTask }) => { + if ((item as any)._empty) { + return ( + + {t('calendar20.noEvents')} + + ); + } + + const isCompleted = item.status?.toLowerCase() === 'completato' || item.status?.toLowerCase() === 'completed'; + const timeStr = item.isAllDay + ? t('calendar20.allDay') + : `${item.startDayjs.format('HH:mm')} - ${item.endDayjs.format('HH:mm')}`; + + return ( + onTaskPress(item)} + > + onToggleComplete(item)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.checkbox} + > + + + + + + + + {item.title} + + {timeStr} + + + {item.category_name && ( + + {item.category_name} + + )} + + ); + }, [onTaskPress, onToggleComplete, t]); + + const renderSectionHeader = useCallback(({ section }: { section: AgendaSection }) => ( + + + {section.title} + + + ), []); + + return ( + item.task_id || item.id || `empty-${index}`} + stickySectionHeadersEnabled={true} + style={styles.container} + contentContainerStyle={styles.contentContainer} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + + + Load more + + } + /> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingBottom: 80, + }, + sectionHeader: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#f8f9fa', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + todayHeader: { + backgroundColor: '#EBF2FF', + }, + sectionTitle: { + fontSize: 14, + fontWeight: '500', + color: '#333333', + fontFamily: 'System', + letterSpacing: -0.3, + }, + todayTitle: { + color: '#007AFF', + fontWeight: '600', + }, + taskRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#ffffff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f0f0f0', + }, + checkbox: { + marginRight: 8, + }, + colorDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 10, + }, + taskContent: { + flex: 1, + }, + taskTitle: { + fontSize: 15, + fontWeight: '400', + color: '#000000', + fontFamily: 'System', + }, + completedTitle: { + textDecorationLine: 'line-through', + color: '#999999', + }, + taskTime: { + fontSize: 12, + color: '#666666', + fontFamily: 'System', + marginTop: 2, + }, + categoryBadge: { + fontSize: 11, + fontWeight: '500', + fontFamily: 'System', + maxWidth: 80, + }, + emptyDay: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#ffffff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f0f0f0', + }, + emptyText: { + fontSize: 14, + color: '#cccccc', + fontFamily: 'System', + fontStyle: 'italic', + }, + loadMoreButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + gap: 6, + }, + loadMoreText: { + fontSize: 14, + color: '#007AFF', + fontWeight: '500', + fontFamily: 'System', + }, +}); + +export default React.memo(AgendaView); diff --git a/src/components/Calendar20/Calendar20View.tsx b/src/components/Calendar20/Calendar20View.tsx new file mode 100644 index 0000000..0bd4aa4 --- /dev/null +++ b/src/components/Calendar20/Calendar20View.tsx @@ -0,0 +1,413 @@ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { Task, getAllTasks, getCategories, completeTask, disCompleteTask } from '../../services/taskService'; +import { TaskCacheService } from '../../services/TaskCacheService'; +import AppInitializer from '../../services/AppInitializer'; +import eventEmitter, { EVENTS } from '../../utils/eventEmitter'; +import { useFocusEffect } from '@react-navigation/native'; +import CategoryColorService from './categoryColors'; +import { CalendarViewType, CalendarTask } from './types'; +import TopBar from './TopBar'; +import MonthView from './MonthView'; +import WeekView from './WeekView'; +import ThreeDayView from './ThreeDayView'; +import DayView from './DayView'; +import AgendaView from './AgendaView'; +import MiniCalendar from './MiniCalendar'; +import ViewDrawer from './ViewDrawer'; +import SearchOverlay from './SearchOverlay'; +import FABMenu from './FABMenu'; +import AddTask from '../Task/AddTask'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +dayjs.extend(isoWeek); + +const VIEW_PREF_KEY = '@calendar20_view_pref'; + +const Calendar20View: React.FC = () => { + const [viewType, setViewType] = useState('month'); + const [currentDate, setCurrentDate] = useState(dayjs()); + const [rawTasks, setRawTasks] = useState([]); + const [categories, setCategories] = useState([]); + const [enabledCategories, setEnabledCategories] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [drawerVisible, setDrawerVisible] = useState(false); + const [searchVisible, setSearchVisible] = useState(false); + const [miniCalendarVisible, setMiniCalendarVisible] = useState(false); + const [addTaskVisible, setAddTaskVisible] = useState(false); + + const cacheService = useRef(TaskCacheService.getInstance()).current; + const appInitializer = useRef(AppInitializer.getInstance()).current; + const colorService = useRef(CategoryColorService.getInstance()).current; + + // Load saved view preference + useEffect(() => { + (async () => { + try { + const saved = await AsyncStorage.getItem(VIEW_PREF_KEY); + if (saved) setViewType(saved as CalendarViewType); + } catch {} + })(); + }, []); + + // Enrich tasks with colors and dayjs + const calendarTasks = useMemo((): CalendarTask[] => { + return rawTasks.map(task => { + const categoryName = task.category_name || ''; + const displayColor = colorService.getColor(categoryName); + const startDayjs = task.start_time ? dayjs(task.start_time) : dayjs(); + const endDayjs = task.end_time ? dayjs(task.end_time) : startDayjs; + const durationMinutes = endDayjs.diff(startDayjs, 'minute'); + const isMultiDay = !startDayjs.isSame(endDayjs, 'day'); + const isAllDay = durationMinutes >= 1440 || (!task.start_time && !!task.end_time); + + return { + ...task, + displayColor, + startDayjs, + endDayjs, + durationMinutes: Math.max(durationMinutes, 30), // min 30 min for display + isMultiDay, + isAllDay, + }; + }); + }, [rawTasks, colorService]); + + // Filtered tasks by enabled categories + const filteredTasks = useMemo(() => { + if (enabledCategories.size === 0) return calendarTasks; + return calendarTasks.filter(t => { + const cat = (t.category_name || '').toLowerCase().trim(); + return enabledCategories.has(cat); + }); + }, [calendarTasks, enabledCategories]); + + // Fetch tasks + const fetchTasks = useCallback(async () => { + try { + setIsLoading(true); + + // Load color service + await colorService.load(); + + // Try AppInitializer cache first + if (appInitializer.isDataReady()) { + const cachedTasks = await cacheService.getCachedTasks(); + if (cachedTasks.length > 0) { + setRawTasks(cachedTasks); + setIsLoading(false); + + // Load categories + const cats = await cacheService.getCachedCategories(); + setCategories(cats); + colorService.assignColors(cats.map((c: any) => c.name)); + return; + } + } + + // Wait for data + const dataReady = await appInitializer.waitForDataLoad(3000); + if (dataReady) { + const cachedTasks = await cacheService.getCachedTasks(); + if (cachedTasks.length > 0) { + setRawTasks(cachedTasks); + setIsLoading(false); + const cats = await cacheService.getCachedCategories(); + setCategories(cats); + colorService.assignColors(cats.map((c: any) => c.name)); + return; + } + } + + // Fallback to API + const [tasksData, catsData] = await Promise.all([ + getAllTasks(true), + getCategories(true), + ]); + if (Array.isArray(tasksData)) setRawTasks(tasksData); + if (Array.isArray(catsData)) { + setCategories(catsData); + colorService.assignColors(catsData.map((c: any) => c.name)); + } + } catch (error) { + console.error('[CALENDAR20] Error loading tasks:', error); + const cachedTasks = await cacheService.getCachedTasks(); + if (cachedTasks.length > 0) setRawTasks(cachedTasks); + } finally { + setIsLoading(false); + } + }, [cacheService, appInitializer, colorService]); + + // Initial load + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + // Refresh on focus + useFocusEffect( + useCallback(() => { + fetchTasks(); + }, [fetchTasks]) + ); + + // Event emitter subscriptions + useEffect(() => { + const handleTaskAdded = (newTask: Task) => { + setRawTasks(prev => { + if (prev.some(t => (t.id === newTask.id) || (t.task_id === newTask.task_id))) return prev; + return [...prev, newTask]; + }); + }; + + const handleTaskUpdated = (updatedTask: Task) => { + setRawTasks(prev => + prev.map(t => { + const isMatch = + (t.id === updatedTask.id) || + (t.task_id === updatedTask.task_id) || + (updatedTask.id && t.task_id === updatedTask.id) || + (updatedTask.task_id && t.id === updatedTask.task_id); + return isMatch ? { ...t, ...updatedTask } : t; + }) + ); + }; + + const handleTaskDeleted = (taskId: string | number) => { + setRawTasks(prev => prev.filter(t => t.id !== taskId && t.task_id !== taskId)); + }; + + const handleTasksSynced = ({ tasks }: { tasks: Task[] }) => { + if (Array.isArray(tasks)) setRawTasks(tasks); + }; + + eventEmitter.on(EVENTS.TASK_ADDED, handleTaskAdded); + eventEmitter.on(EVENTS.TASK_UPDATED, handleTaskUpdated); + eventEmitter.on(EVENTS.TASK_DELETED, handleTaskDeleted); + eventEmitter.on(EVENTS.TASKS_SYNCED, handleTasksSynced); + + return () => { + eventEmitter.off(EVENTS.TASK_ADDED, handleTaskAdded); + eventEmitter.off(EVENTS.TASK_UPDATED, handleTaskUpdated); + eventEmitter.off(EVENTS.TASK_DELETED, handleTaskDeleted); + eventEmitter.off(EVENTS.TASKS_SYNCED, handleTasksSynced); + }; + }, []); + + // Navigation + const navigateDate = useCallback((direction: 'prev' | 'next') => { + setCurrentDate(prev => { + switch (viewType) { + case 'month': + return direction === 'next' ? prev.add(1, 'month') : prev.subtract(1, 'month'); + case 'week': + return direction === 'next' ? prev.add(1, 'week') : prev.subtract(1, 'week'); + case '3day': + return direction === 'next' ? prev.add(1, 'day') : prev.subtract(1, 'day'); + case 'day': + return direction === 'next' ? prev.add(1, 'day') : prev.subtract(1, 'day'); + case 'agenda': + return direction === 'next' ? prev.add(1, 'month') : prev.subtract(1, 'month'); + default: + return prev; + } + }); + }, [viewType]); + + const handleViewChange = useCallback(async (newView: CalendarViewType) => { + setViewType(newView); + setDrawerVisible(false); + try { + await AsyncStorage.setItem(VIEW_PREF_KEY, newView); + } catch {} + }, []); + + const handleDatePress = useCallback((date: dayjs.Dayjs) => { + setCurrentDate(date); + if (viewType === 'month') { + setViewType('day'); + } + }, [viewType]); + + const handleTaskPress = useCallback((task: CalendarTask) => { + // Navigate to day view for the task + setCurrentDate(task.startDayjs); + if (viewType === 'month') { + setViewType('day'); + } + }, [viewType]); + + const handleToggleComplete = useCallback(async (task: CalendarTask) => { + const taskId = task.task_id || task.id; + const isCompleted = task.status?.toLowerCase() === 'completato' || task.status?.toLowerCase() === 'completed'; + try { + if (isCompleted) { + await disCompleteTask(taskId); + } else { + await completeTask(taskId); + } + } catch (error) { + console.error('[CALENDAR20] Error toggling task completion:', error); + } + }, []); + + const handleSaveTask = useCallback(async ( + title: string, + description: string, + dueDate: string, + priority: number, + categoryNameParam?: string + ) => { + const { addTask } = await import('../../services/taskService'); + const priorityString = priority === 1 ? 'Bassa' : priority === 2 ? 'Media' : 'Alta'; + const category = categoryNameParam || 'Calendario'; + const newTask: Task = { + title: title.trim(), + description: description || '', + start_time: currentDate.toISOString(), + end_time: new Date(dueDate).toISOString(), + priority: priorityString, + status: 'In sospeso', + category_name: category, + }; + try { + await addTask(newTask); + } catch (error) { + console.error('[CALENDAR20] Error adding task:', error); + } + setAddTaskVisible(false); + }, [currentDate]); + + const handleCategoryToggle = useCallback((categoryName: string) => { + setEnabledCategories(prev => { + const next = new Set(prev); + const key = categoryName.toLowerCase().trim(); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + // If all categories are deselected, show all + if (next.size === categories.length) { + return new Set(); + } + return next; + }); + }, [categories.length]); + + const handleShowAll = useCallback(() => { + setEnabledCategories(new Set()); + }, []); + + const renderView = () => { + const commonProps = { + currentDate, + tasks: filteredTasks, + onDatePress: handleDatePress, + onTaskPress: handleTaskPress, + onToggleComplete: handleToggleComplete, + onSwipeLeft: () => navigateDate('next'), + onSwipeRight: () => navigateDate('prev'), + }; + + switch (viewType) { + case 'month': + return ; + case 'week': + return ; + case '3day': + return ; + case 'day': + return ; + case 'agenda': + return ; + default: + return ; + } + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + setDrawerVisible(true)} + onSearchPress={() => setSearchVisible(true)} + onTodayPress={() => setCurrentDate(dayjs())} + onTitlePress={() => setMiniCalendarVisible(true)} + /> + + {renderView()} + + setAddTaskVisible(true)} + /> + + setDrawerVisible(false)} + /> + + { + setSearchVisible(false); + setCurrentDate(task.startDayjs); + setViewType('day'); + }} + onClose={() => setSearchVisible(false)} + /> + + { + setCurrentDate(date); + setMiniCalendarVisible(false); + }} + onClose={() => setMiniCalendarVisible(false)} + /> + + setAddTaskVisible(false)} + onSave={handleSaveTask} + allowCategorySelection={true} + categoryName="Calendario" + initialDate={currentDate.format('YYYY-MM-DD')} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#ffffff', + }, +}); + +export default Calendar20View; diff --git a/src/components/Calendar20/DayView.tsx b/src/components/Calendar20/DayView.tsx new file mode 100644 index 0000000..5e9878b --- /dev/null +++ b/src/components/Calendar20/DayView.tsx @@ -0,0 +1,289 @@ +import React, { useMemo, useRef, useEffect } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + Dimensions, + PanResponder, +} from 'react-native'; +import dayjs from 'dayjs'; +import { CalendarTask, OverlapColumn } from './types'; +import TimeBlock from './TimeBlock'; +import EventChip from './EventChip'; +import { useTranslation } from 'react-i18next'; + +const HOUR_HEIGHT = 60; +const TIME_LABEL_WIDTH = 48; +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const COLUMN_WIDTH = SCREEN_WIDTH - TIME_LABEL_WIDTH - 12; + +interface DayViewProps { + currentDate: dayjs.Dayjs; + tasks: CalendarTask[]; + onDatePress: (date: dayjs.Dayjs) => void; + onTaskPress: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + onSwipeLeft: () => void; + onSwipeRight: () => void; +} + +function computeOverlapColumns(tasks: CalendarTask[]): OverlapColumn[] { + if (tasks.length === 0) return []; + + const sorted = [...tasks].sort((a, b) => { + const diff = a.startDayjs.valueOf() - b.startDayjs.valueOf(); + if (diff !== 0) return diff; + return b.durationMinutes - a.durationMinutes; + }); + + const columns: OverlapColumn[] = []; + const endTimes: number[] = []; // tracks end time per column + + for (const task of sorted) { + const start = task.startDayjs.valueOf(); + let placed = false; + + for (let col = 0; col < endTimes.length; col++) { + if (start >= endTimes[col]) { + endTimes[col] = task.endDayjs.valueOf(); + columns.push({ task, column: col, totalColumns: 0 }); + placed = true; + break; + } + } + + if (!placed) { + endTimes.push(task.endDayjs.valueOf()); + columns.push({ task, column: endTimes.length - 1, totalColumns: 0 }); + } + } + + // Compute totalColumns for each group of overlapping events + // Simple approach: set totalColumns to max column + 1 among overlapping peers + for (let i = 0; i < columns.length; i++) { + const entry = columns[i]; + const taskStart = entry.task.startDayjs.valueOf(); + const taskEnd = entry.task.endDayjs.valueOf(); + + let maxCol = entry.column; + for (let j = 0; j < columns.length; j++) { + if (i === j) continue; + const other = columns[j]; + const otherStart = other.task.startDayjs.valueOf(); + const otherEnd = other.task.endDayjs.valueOf(); + // Overlaps? + if (otherStart < taskEnd && otherEnd > taskStart) { + maxCol = Math.max(maxCol, other.column); + } + } + entry.totalColumns = maxCol + 1; + } + + return columns; +} + +const DayView: React.FC = ({ + currentDate, + tasks, + onTaskPress, + onToggleComplete, + onSwipeLeft, + onSwipeRight, +}) => { + const { t } = useTranslation(); + const scrollRef = useRef(null); + + // Scroll to current time on mount + useEffect(() => { + const now = dayjs(); + if (currentDate.isSame(now, 'day')) { + const offset = Math.max(0, (now.hour() - 1) * HOUR_HEIGHT); + setTimeout(() => scrollRef.current?.scrollTo({ y: offset, animated: false }), 100); + } else { + setTimeout(() => scrollRef.current?.scrollTo({ y: 7 * HOUR_HEIGHT, animated: false }), 100); + } + }, [currentDate]); + + const swipeRef = useRef({ swiped: false }); + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gs) => Math.abs(gs.dx) > 20 && Math.abs(gs.dy) < 20, + onPanResponderGrant: () => { + swipeRef.current.swiped = false; + }, + onPanResponderRelease: (_, gs) => { + if (swipeRef.current.swiped) return; + if (gs.dx > 60) { + swipeRef.current.swiped = true; + onSwipeRight(); + } else if (gs.dx < -60) { + swipeRef.current.swiped = true; + onSwipeLeft(); + } + }, + }) + ).current; + + const dayTasks = useMemo(() => { + return tasks.filter(task => { + return ( + currentDate.isSame(task.startDayjs, 'day') || + currentDate.isSame(task.endDayjs, 'day') || + (currentDate.isAfter(task.startDayjs, 'day') && currentDate.isBefore(task.endDayjs, 'day')) + ); + }); + }, [tasks, currentDate]); + + const allDayTasks = useMemo(() => dayTasks.filter(t => t.isAllDay), [dayTasks]); + const timedTasks = useMemo(() => dayTasks.filter(t => !t.isAllDay), [dayTasks]); + const overlapColumns = useMemo(() => computeOverlapColumns(timedTasks), [timedTasks]); + + const now = dayjs(); + const isToday = currentDate.isSame(now, 'day'); + const currentTimeTop = isToday ? (now.hour() + now.minute() / 60) * HOUR_HEIGHT : -1; + + const hours = Array.from({ length: 24 }, (_, i) => i); + + return ( + + {/* All-day events */} + {allDayTasks.length > 0 && ( + + {t('calendar20.allDay')} + + {allDayTasks.map(task => ( + + ))} + + + )} + + + + {/* Time labels */} + + {hours.map(hour => ( + + + {hour.toString().padStart(2, '0')}:00 + + + ))} + + + {/* Events column */} + + {/* Hour grid lines */} + {hours.map(hour => ( + + ))} + + {/* Time blocks */} + {overlapColumns.map(({ task, column, totalColumns }) => ( + + ))} + + {/* Current time indicator */} + {isToday && currentTimeTop >= 0 && ( + + + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + allDayContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + backgroundColor: '#fafafa', + }, + allDayLabel: { + width: TIME_LABEL_WIDTH - 12, + fontSize: 10, + fontWeight: '500', + color: '#666666', + fontFamily: 'System', + }, + allDayChips: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + }, + scrollContainer: { + flex: 1, + }, + gridContainer: { + flexDirection: 'row', + paddingHorizontal: 6, + }, + timeColumn: { + width: TIME_LABEL_WIDTH, + }, + timeSlot: { + justifyContent: 'flex-start', + }, + timeLabel: { + fontSize: 11, + color: '#999999', + fontFamily: 'System', + fontWeight: '400', + marginTop: -6, + }, + eventsColumn: { + flex: 1, + height: 24 * HOUR_HEIGHT, + position: 'relative', + }, + hourLine: { + position: 'absolute', + left: 0, + right: 0, + height: StyleSheet.hairlineWidth, + backgroundColor: '#e1e5e9', + }, + currentTimeLine: { + position: 'absolute', + left: -6, + right: 0, + flexDirection: 'row', + alignItems: 'center', + zIndex: 10, + }, + currentTimeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#EA4335', + }, + currentTimeBar: { + flex: 1, + height: 1.5, + backgroundColor: '#EA4335', + }, +}); + +export default React.memo(DayView); diff --git a/src/components/Calendar20/EventChip.tsx b/src/components/Calendar20/EventChip.tsx new file mode 100644 index 0000000..6a22f92 --- /dev/null +++ b/src/components/Calendar20/EventChip.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { CalendarTask } from './types'; + +interface EventChipProps { + task: CalendarTask; + onPress?: (task: CalendarTask) => void; + isSpanning?: boolean; + isStart?: boolean; + isEnd?: boolean; +} + +const EventChip: React.FC = ({ task, onPress, isSpanning, isStart = true, isEnd = true }) => { + const bgColor = task.displayColor || '#4285F4'; + const isCompleted = task.status?.toLowerCase() === 'completato' || task.status?.toLowerCase() === 'completed'; + + return ( + onPress?.(task)} + style={[ + styles.chip, + { backgroundColor: bgColor }, + isSpanning && !isStart && styles.spanningLeft, + isSpanning && !isEnd && styles.spanningRight, + isCompleted && styles.completed, + ]} + > + + {task.title} + + + ); +}; + +const styles = StyleSheet.create({ + chip: { + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: 3, + marginBottom: 1, + minHeight: 16, + justifyContent: 'center', + }, + spanningLeft: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + marginLeft: -1, + }, + spanningRight: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + marginRight: -1, + }, + chipText: { + color: '#ffffff', + fontSize: 10, + fontWeight: '500', + fontFamily: 'System', + }, + completed: { + opacity: 0.5, + }, + completedText: { + textDecorationLine: 'line-through', + }, +}); + +export default React.memo(EventChip); diff --git a/src/components/Calendar20/FABMenu.tsx b/src/components/Calendar20/FABMenu.tsx new file mode 100644 index 0000000..66892ec --- /dev/null +++ b/src/components/Calendar20/FABMenu.tsx @@ -0,0 +1,177 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Animated, + Pressable, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +interface FABMenuProps { + onNewTask: () => void; +} + +const FABMenu: React.FC = ({ onNewTask }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const animValue = useRef(new Animated.Value(0)).current; + const rotateValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.spring(animValue, { + toValue: expanded ? 1 : 0, + useNativeDriver: true, + tension: 50, + friction: 8, + }), + Animated.spring(rotateValue, { + toValue: expanded ? 1 : 0, + useNativeDriver: true, + tension: 50, + friction: 8, + }), + ]).start(); + }, [expanded, animValue, rotateValue]); + + const rotate = rotateValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '45deg'], + }); + + const handleNewTask = () => { + setExpanded(false); + onNewTask(); + }; + + return ( + <> + {/* Backdrop when expanded */} + {expanded && ( + setExpanded(false)} /> + )} + + + {/* Task option */} + + + + + + + {t('calendar20.fab.newTask')} + + + + + {/* Main FAB */} + setExpanded(!expanded)} + activeOpacity={0.8} + > + + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.15)', + zIndex: 99, + }, + container: { + position: 'absolute', + bottom: 24, + right: 20, + alignItems: 'flex-end', + zIndex: 100, + }, + fab: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#000000', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 6, + }, + optionContainer: { + position: 'absolute', + bottom: 0, + right: 0, + flexDirection: 'row', + alignItems: 'center', + }, + optionButton: { + flexDirection: 'row', + alignItems: 'center', + }, + optionLabel: { + backgroundColor: '#ffffff', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + marginRight: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + optionText: { + fontSize: 13, + fontWeight: '500', + color: '#333333', + fontFamily: 'System', + }, + optionIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 3, + }, +}); + +export default React.memo(FABMenu); diff --git a/src/components/Calendar20/MiniCalendar.tsx b/src/components/Calendar20/MiniCalendar.tsx new file mode 100644 index 0000000..af3f0f8 --- /dev/null +++ b/src/components/Calendar20/MiniCalendar.tsx @@ -0,0 +1,224 @@ +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + StyleSheet, + Pressable, +} from 'react-native'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +dayjs.extend(isoWeek); + +interface MiniCalendarProps { + visible: boolean; + currentDate: dayjs.Dayjs; + onDateSelect: (date: dayjs.Dayjs) => void; + onClose: () => void; +} + +const MiniCalendar: React.FC = ({ + visible, + currentDate, + onDateSelect, + onClose, +}) => { + const { t } = useTranslation(); + const [displayMonth, setDisplayMonth] = useState(currentDate); + + const weeks = useMemo(() => { + const startOfMonth = displayMonth.startOf('month'); + const endOfMonth = displayMonth.endOf('month'); + const startDate = startOfMonth.startOf('isoWeek'); + const endDate = endOfMonth.endOf('isoWeek'); + const today = dayjs(); + + const result: { date: dayjs.Dayjs; isCurrentMonth: boolean; isToday: boolean; isSelected: boolean }[][] = []; + let current = startDate; + + while (current.isBefore(endDate) || current.isSame(endDate, 'day')) { + const week: typeof result[0] = []; + for (let i = 0; i < 7; i++) { + week.push({ + date: current, + isCurrentMonth: current.month() === displayMonth.month(), + isToday: current.isSame(today, 'day'), + isSelected: current.isSame(currentDate, 'day'), + }); + current = current.add(1, 'day'); + } + result.push(week); + } + return result; + }, [displayMonth, currentDate]); + + const dayHeaders = useMemo(() => { + const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + return days.map(d => t(`calendar.days.${d}`)); + }, [t]); + + if (!visible) return null; + + return ( + + + e.stopPropagation()}> + {/* Month navigation */} + + setDisplayMonth(p => p.subtract(1, 'month'))}> + + + {displayMonth.format('MMMM YYYY')} + setDisplayMonth(p => p.add(1, 'month'))}> + + + + + {/* Day headers */} + + {dayHeaders.map((d, i) => ( + {d.toUpperCase()} + ))} + + + {/* Weeks */} + {weeks.map((week, wi) => ( + + {week.map((day, di) => ( + onDateSelect(day.date)} + style={[ + styles.dayCell, + day.isSelected && styles.selectedCell, + day.isToday && !day.isSelected && styles.todayCell, + ]} + > + + {day.date.date()} + + + ))} + + ))} + + {/* Today button */} + { + setDisplayMonth(dayjs()); + onDateSelect(dayjs()); + }} + > + {t('calendar20.today')} + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.3)', + justifyContent: 'flex-start', + paddingTop: 80, + alignItems: 'center', + }, + container: { + backgroundColor: '#ffffff', + borderRadius: 16, + padding: 16, + width: 320, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + monthNav: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + monthTitle: { + fontSize: 16, + fontWeight: '500', + color: '#000000', + fontFamily: 'System', + letterSpacing: -0.3, + }, + headerRow: { + flexDirection: 'row', + marginBottom: 4, + }, + headerText: { + flex: 1, + textAlign: 'center', + fontSize: 11, + fontWeight: '500', + color: '#999999', + fontFamily: 'System', + }, + weekRow: { + flexDirection: 'row', + }, + dayCell: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 6, + }, + selectedCell: { + backgroundColor: '#007AFF', + borderRadius: 14, + }, + todayCell: { + borderWidth: 1, + borderColor: '#007AFF', + borderRadius: 14, + }, + dayText: { + fontSize: 14, + fontWeight: '400', + color: '#000000', + fontFamily: 'System', + }, + otherMonthText: { + color: '#cccccc', + }, + selectedText: { + color: '#ffffff', + fontWeight: '600', + }, + todayText: { + color: '#007AFF', + fontWeight: '600', + }, + todayButton: { + marginTop: 12, + alignItems: 'center', + paddingVertical: 8, + }, + todayButtonText: { + fontSize: 14, + fontWeight: '500', + color: '#007AFF', + fontFamily: 'System', + }, +}); + +export default React.memo(MiniCalendar); diff --git a/src/components/Calendar20/MonthView.tsx b/src/components/Calendar20/MonthView.tsx new file mode 100644 index 0000000..1b9aa18 --- /dev/null +++ b/src/components/Calendar20/MonthView.tsx @@ -0,0 +1,243 @@ +import React, { useMemo, useRef } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Dimensions, + PanResponder, +} from 'react-native'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { CalendarTask, DayData } from './types'; +import EventChip from './EventChip'; +import { useTranslation } from 'react-i18next'; + +dayjs.extend(isoWeek); + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const DAY_WIDTH = (SCREEN_WIDTH - 24) / 7; +const MAX_CHIPS = 3; + +interface MonthViewProps { + currentDate: dayjs.Dayjs; + tasks: CalendarTask[]; + onDatePress: (date: dayjs.Dayjs) => void; + onTaskPress: (task: CalendarTask) => void; + onSwipeLeft: () => void; + onSwipeRight: () => void; +} + +const MonthView: React.FC = ({ + currentDate, + tasks, + onDatePress, + onTaskPress, + onSwipeLeft, + onSwipeRight, +}) => { + const { t } = useTranslation(); + + const swipeRef = useRef({ x: 0, swiped: false }); + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gs) => Math.abs(gs.dx) > 15 && Math.abs(gs.dy) < 30, + onPanResponderGrant: (_, gs) => { + swipeRef.current = { x: gs.x0, swiped: false }; + }, + onPanResponderRelease: (_, gs) => { + if (swipeRef.current.swiped) return; + if (gs.dx > 60) { + swipeRef.current.swiped = true; + onSwipeRight(); + } else if (gs.dx < -60) { + swipeRef.current.swiped = true; + onSwipeLeft(); + } + }, + }) + ).current; + + const weeks = useMemo(() => { + const startOfMonth = currentDate.startOf('month'); + const endOfMonth = currentDate.endOf('month'); + // Start from Monday of the week containing the 1st + const startDate = startOfMonth.startOf('isoWeek'); + // End on Sunday of the week containing the last day + const endDate = endOfMonth.endOf('isoWeek'); + + const today = dayjs(); + const result: DayData[][] = []; + let current = startDate; + + while (current.isBefore(endDate) || current.isSame(endDate, 'day')) { + const week: DayData[] = []; + for (let i = 0; i < 7; i++) { + const dateStr = current.format('YYYY-MM-DD'); + const dayTasks = tasks.filter(task => { + if (!task.endDayjs && !task.startDayjs) return false; + const taskStart = task.startDayjs; + const taskEnd = task.endDayjs; + // Task falls on this day if the day is between start and end (inclusive) + return ( + current.isSame(taskEnd, 'day') || + current.isSame(taskStart, 'day') || + (current.isAfter(taskStart, 'day') && current.isBefore(taskEnd, 'day')) + ); + }); + + week.push({ + date: current, + dateString: dateStr, + isCurrentMonth: current.month() === currentDate.month(), + isToday: current.isSame(today, 'day'), + tasks: dayTasks, + }); + current = current.add(1, 'day'); + } + result.push(week); + } + return result; + }, [currentDate, tasks]); + + const dayHeaders = useMemo(() => { + const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + return days.map(d => t(`calendar.days.${d}`)); + }, [t]); + + return ( + + {/* Day headers */} + + {dayHeaders.map((day, i) => ( + + + {day.toUpperCase()} + + + ))} + + + {/* Week rows */} + {weeks.map((week, wi) => ( + + {week.map((day, di) => { + const extraCount = Math.max(0, day.tasks.length - MAX_CHIPS); + return ( + onDatePress(day.date)} + > + + + {day.date.date()} + + + + {day.tasks.slice(0, MAX_CHIPS).map(task => ( + + ))} + {extraCount > 0 && ( + + {`+${extraCount}`} + + )} + + + ); + })} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 12, + }, + headerRow: { + flexDirection: 'row', + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + headerCell: { + width: DAY_WIDTH, + alignItems: 'center', + }, + headerText: { + fontSize: 12, + fontWeight: '500', + color: '#666666', + fontFamily: 'System', + }, + weekendHeader: { + color: '#999999', + }, + weekRow: { + flexDirection: 'row', + minHeight: 72, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f0f0f0', + }, + dayCell: { + width: DAY_WIDTH, + paddingTop: 2, + paddingHorizontal: 1, + }, + dateCircle: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + marginBottom: 2, + }, + todayCircle: { + backgroundColor: '#007AFF', + }, + dateText: { + fontSize: 13, + fontWeight: '400', + color: '#000000', + fontFamily: 'System', + }, + otherMonthText: { + color: '#cccccc', + }, + todayText: { + color: '#ffffff', + fontWeight: '600', + }, + weekendText: { + color: '#999999', + }, + chipsContainer: { + flex: 1, + }, + moreText: { + fontSize: 10, + color: '#666666', + fontFamily: 'System', + textAlign: 'center', + marginTop: 1, + }, +}); + +export default React.memo(MonthView); diff --git a/src/components/Calendar20/SearchOverlay.tsx b/src/components/Calendar20/SearchOverlay.tsx new file mode 100644 index 0000000..f13473d --- /dev/null +++ b/src/components/Calendar20/SearchOverlay.tsx @@ -0,0 +1,215 @@ +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + TextInput, + FlatList, + TouchableOpacity, + Modal, + StyleSheet, + Pressable, + Keyboard, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarTask } from './types'; +import { useTranslation } from 'react-i18next'; + +interface SearchOverlayProps { + visible: boolean; + tasks: CalendarTask[]; + onTaskPress: (task: CalendarTask) => void; + onClose: () => void; +} + +const SearchOverlay: React.FC = ({ + visible, + tasks, + onTaskPress, + onClose, +}) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + + const results = useMemo(() => { + if (!query.trim()) return []; + const q = query.toLowerCase().trim(); + return tasks + .filter(task => { + const title = (task.title || '').toLowerCase(); + const desc = (task.description || '').toLowerCase(); + const category = (task.category_name || '').toLowerCase(); + return title.includes(q) || desc.includes(q) || category.includes(q); + }) + .sort((a, b) => a.startDayjs.valueOf() - b.startDayjs.valueOf()) + .slice(0, 50); + }, [query, tasks]); + + const handleClose = () => { + setQuery(''); + Keyboard.dismiss(); + onClose(); + }; + + if (!visible) return null; + + return ( + + + e.stopPropagation()}> + {/* Search bar */} + + + + {query.length > 0 && ( + setQuery('')}> + + + )} + + {t('common.buttons.cancel')} + + + + {/* Results */} + {query.trim().length > 0 && results.length === 0 && ( + + + {t('calendar20.search.noResults')} + + )} + + (item.task_id || item.id || i).toString()} + renderItem={({ item }) => ( + { + handleClose(); + onTaskPress(item); + }} + > + + + {item.title} + + {item.startDayjs.format('ddd, D MMM YYYY')} + {!item.isAllDay && ` ${item.startDayjs.format('HH:mm')}`} + + + {item.category_name && ( + + {item.category_name} + + )} + + )} + keyboardShouldPersistTaps="handled" + style={styles.resultsList} + /> + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.3)', + }, + container: { + flex: 1, + backgroundColor: '#ffffff', + marginTop: 40, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + backgroundColor: '#fafafa', + }, + input: { + flex: 1, + fontSize: 16, + fontFamily: 'System', + color: '#000000', + marginLeft: 8, + marginRight: 8, + paddingVertical: 4, + }, + cancelButton: { + marginLeft: 8, + }, + cancelText: { + fontSize: 14, + fontWeight: '500', + color: '#007AFF', + fontFamily: 'System', + }, + resultsList: { + flex: 1, + }, + resultRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f0f0f0', + }, + colorDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 12, + }, + resultContent: { + flex: 1, + }, + resultTitle: { + fontSize: 15, + fontWeight: '400', + color: '#000000', + fontFamily: 'System', + }, + resultDate: { + fontSize: 12, + color: '#666666', + fontFamily: 'System', + marginTop: 2, + }, + resultCategory: { + fontSize: 11, + fontWeight: '500', + fontFamily: 'System', + maxWidth: 80, + }, + noResults: { + alignItems: 'center', + justifyContent: 'center', + paddingTop: 60, + }, + noResultsText: { + fontSize: 15, + color: '#999999', + fontFamily: 'System', + marginTop: 12, + }, +}); + +export default React.memo(SearchOverlay); diff --git a/src/components/Calendar20/ThreeDayView.tsx b/src/components/Calendar20/ThreeDayView.tsx new file mode 100644 index 0000000..65d6865 --- /dev/null +++ b/src/components/Calendar20/ThreeDayView.tsx @@ -0,0 +1,299 @@ +import React, { useMemo, useRef, useEffect } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + Dimensions, + PanResponder, +} from 'react-native'; +import dayjs from 'dayjs'; +import { CalendarTask, OverlapColumn } from './types'; +import TimeBlock from './TimeBlock'; +import EventChip from './EventChip'; +import { useTranslation } from 'react-i18next'; + +const HOUR_HEIGHT = 60; +const TIME_LABEL_WIDTH = 44; +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 12) / 3; + +interface ThreeDayViewProps { + currentDate: dayjs.Dayjs; + tasks: CalendarTask[]; + onDatePress: (date: dayjs.Dayjs) => void; + onTaskPress: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + onSwipeLeft: () => void; + onSwipeRight: () => void; +} + +function computeOverlapColumns(tasks: CalendarTask[]): OverlapColumn[] { + if (tasks.length === 0) return []; + const sorted = [...tasks].sort((a, b) => { + const diff = a.startDayjs.valueOf() - b.startDayjs.valueOf(); + return diff !== 0 ? diff : b.durationMinutes - a.durationMinutes; + }); + const columns: OverlapColumn[] = []; + const endTimes: number[] = []; + for (const task of sorted) { + const start = task.startDayjs.valueOf(); + let placed = false; + for (let col = 0; col < endTimes.length; col++) { + if (start >= endTimes[col]) { + endTimes[col] = task.endDayjs.valueOf(); + columns.push({ task, column: col, totalColumns: 0 }); + placed = true; + break; + } + } + if (!placed) { + endTimes.push(task.endDayjs.valueOf()); + columns.push({ task, column: endTimes.length - 1, totalColumns: 0 }); + } + } + for (let i = 0; i < columns.length; i++) { + const e = columns[i]; + const s = e.task.startDayjs.valueOf(); + const en = e.task.endDayjs.valueOf(); + let maxCol = e.column; + for (let j = 0; j < columns.length; j++) { + if (i === j) continue; + const o = columns[j]; + if (o.task.startDayjs.valueOf() < en && o.task.endDayjs.valueOf() > s) { + maxCol = Math.max(maxCol, o.column); + } + } + e.totalColumns = maxCol + 1; + } + return columns; +} + +const ThreeDayView: React.FC = ({ + currentDate, + tasks, + onDatePress, + onTaskPress, + onToggleComplete, + onSwipeLeft, + onSwipeRight, +}) => { + const { t } = useTranslation(); + const scrollRef = useRef(null); + + const swipeRef = useRef({ swiped: false }); + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gs) => Math.abs(gs.dx) > 20 && Math.abs(gs.dy) < 20, + onPanResponderGrant: () => { swipeRef.current.swiped = false; }, + onPanResponderRelease: (_, gs) => { + if (swipeRef.current.swiped) return; + if (gs.dx > 60) { swipeRef.current.swiped = true; onSwipeRight(); } + else if (gs.dx < -60) { swipeRef.current.swiped = true; onSwipeLeft(); } + }, + }) + ).current; + + const days = useMemo(() => { + return Array.from({ length: 3 }, (_, i) => currentDate.add(i, 'day')); + }, [currentDate]); + + const allDayTasksByDay = useMemo(() => { + return days.map(day => + tasks.filter(task => { + if (!task.isAllDay) return false; + return ( + day.isSame(task.startDayjs, 'day') || + day.isSame(task.endDayjs, 'day') || + (day.isAfter(task.startDayjs, 'day') && day.isBefore(task.endDayjs, 'day')) + ); + }) + ); + }, [days, tasks]); + + const hasAllDay = allDayTasksByDay.some(d => d.length > 0); + + const timedTasksByDay = useMemo(() => { + return days.map(day => { + const dayTasks = tasks.filter(task => { + if (task.isAllDay) return false; + return ( + day.isSame(task.startDayjs, 'day') || + day.isSame(task.endDayjs, 'day') || + (day.isAfter(task.startDayjs, 'day') && day.isBefore(task.endDayjs, 'day')) + ); + }); + return computeOverlapColumns(dayTasks); + }); + }, [days, tasks]); + + useEffect(() => { + const now = dayjs(); + const offset = Math.max(0, (now.hour() - 1) * HOUR_HEIGHT); + setTimeout(() => scrollRef.current?.scrollTo({ y: offset, animated: false }), 100); + }, [currentDate]); + + const now = dayjs(); + const hours = Array.from({ length: 24 }, (_, i) => i); + + return ( + + {/* Day headers */} + + + {days.map((day, i) => { + const isToday = day.isSame(now, 'day'); + return ( + + + {day.format('ddd').toUpperCase()} + + + + {day.date()} + + + + ); + })} + + + {/* All-day strip */} + {hasAllDay && ( + + + {t('calendar20.allDay')} + + {days.map((_, i) => ( + + {allDayTasksByDay[i].map(task => ( + + ))} + + ))} + + )} + + {/* Time grid */} + + + + {hours.map(hour => ( + + {hour.toString().padStart(2, '0')}:00 + + ))} + + + {days.map((day, dayIndex) => { + const isToday = day.isSame(now, 'day'); + const currentTimeTop = isToday ? (now.hour() + now.minute() / 60) * HOUR_HEIGHT : -1; + + return ( + + {hours.map(hour => ( + + ))} + + {timedTasksByDay[dayIndex].map(({ task, column, totalColumns }) => ( + + ))} + + {isToday && currentTimeTop >= 0 && ( + + + + + )} + + ); + })} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + headerRow: { + flexDirection: 'row', + paddingHorizontal: 6, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + dayHeader: { alignItems: 'center' }, + dayName: { + fontSize: 11, + fontWeight: '500', + color: '#666666', + fontFamily: 'System', + }, + todayColor: { color: '#007AFF' }, + dateCircle: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + todayCircle: { backgroundColor: '#007AFF' }, + dateNum: { fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System' }, + todayDateNum: { color: '#ffffff', fontWeight: '600' }, + allDayRow: { + flexDirection: 'row', + paddingHorizontal: 6, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + backgroundColor: '#fafafa', + }, + allDayLabelText: { fontSize: 10, color: '#666666', fontFamily: 'System' }, + allDayCell: { paddingHorizontal: 1 }, + scrollContainer: { flex: 1 }, + gridContainer: { flexDirection: 'row', paddingHorizontal: 6 }, + dayColumn: { + position: 'relative', + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: '#f0f0f0', + }, + hourLine: { + position: 'absolute', + left: 0, + right: 0, + height: StyleSheet.hairlineWidth, + backgroundColor: '#e1e5e9', + }, + timeLabel: { fontSize: 10, color: '#999999', fontFamily: 'System', marginTop: -5 }, + currentTimeLine: { + position: 'absolute', + left: -2, + right: 0, + flexDirection: 'row', + alignItems: 'center', + zIndex: 10, + }, + currentTimeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#EA4335' }, + currentTimeBar: { flex: 1, height: 1.5, backgroundColor: '#EA4335' }, +}); + +export default React.memo(ThreeDayView); diff --git a/src/components/Calendar20/TimeBlock.tsx b/src/components/Calendar20/TimeBlock.tsx new file mode 100644 index 0000000..01f6ec0 --- /dev/null +++ b/src/components/Calendar20/TimeBlock.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarTask } from './types'; + +interface TimeBlockProps { + task: CalendarTask; + hourHeight: number; + column: number; + totalColumns: number; + columnWidth: number; + onPress?: (task: CalendarTask) => void; + onToggleComplete?: (task: CalendarTask) => void; +} + +const TimeBlock: React.FC = ({ + task, + hourHeight, + column, + totalColumns, + columnWidth, + onPress, + onToggleComplete, +}) => { + const startHour = task.startDayjs.hour() + task.startDayjs.minute() / 60; + const height = Math.max((task.durationMinutes / 60) * hourHeight, 20); + const top = startHour * hourHeight; + const width = columnWidth / totalColumns - 2; + const left = column * (columnWidth / totalColumns) + 1; + + const isCompleted = task.status?.toLowerCase() === 'completato' || task.status?.toLowerCase() === 'completed'; + const bgColor = task.displayColor || '#4285F4'; + + const startTime = task.startDayjs.format('HH:mm'); + const endTime = task.endDayjs.format('HH:mm'); + const showEndTime = height > 35; + + return ( + onPress?.(task)} + style={[ + styles.block, + { + top, + height, + left, + width, + backgroundColor: bgColor, + }, + isCompleted && styles.completed, + ]} + > + + + { + e.stopPropagation?.(); + onToggleComplete?.(task); + }} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + style={styles.checkboxArea} + > + + + + {task.title} + + + {showEndTime && ( + {startTime} - {endTime} + )} + + + ); +}; + +const styles = StyleSheet.create({ + block: { + position: 'absolute', + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2, + overflow: 'hidden', + borderLeftWidth: 3, + borderLeftColor: 'rgba(0,0,0,0.15)', + }, + completed: { + opacity: 0.5, + }, + content: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + }, + checkboxArea: { + marginRight: 3, + }, + title: { + fontSize: 11, + fontWeight: '500', + color: '#ffffff', + fontFamily: 'System', + flex: 1, + }, + completedText: { + textDecorationLine: 'line-through', + }, + time: { + fontSize: 10, + color: 'rgba(255,255,255,0.8)', + fontFamily: 'System', + marginTop: 1, + }, +}); + +export default React.memo(TimeBlock); diff --git a/src/components/Calendar20/TopBar.tsx b/src/components/Calendar20/TopBar.tsx new file mode 100644 index 0000000..939fa3c --- /dev/null +++ b/src/components/Calendar20/TopBar.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Platform, StatusBar } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarViewType } from './types'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; + +interface TopBarProps { + currentDate: dayjs.Dayjs; + viewType: CalendarViewType; + onMenuPress: () => void; + onSearchPress: () => void; + onTodayPress: () => void; + onTitlePress: () => void; +} + +const TopBar: React.FC = ({ + currentDate, + viewType, + onMenuPress, + onSearchPress, + onTodayPress, + onTitlePress, +}) => { + const { t } = useTranslation(); + + const getTitle = (): string => { + switch (viewType) { + case 'month': + return currentDate.format('MMMM YYYY'); + case 'week': + case '3day': { + const start = currentDate.startOf('week'); + const end = start.add(6, 'day'); + if (start.month() === end.month()) { + return `${start.format('D')} - ${end.format('D MMM YYYY')}`; + } + return `${start.format('D MMM')} - ${end.format('D MMM YYYY')}`; + } + case 'day': + return currentDate.format('ddd, D MMMM YYYY'); + case 'agenda': + return currentDate.format('MMMM YYYY'); + default: + return currentDate.format('MMMM YYYY'); + } + }; + + const isToday = currentDate.isSame(dayjs(), 'day'); + + return ( + + + + + + + {getTitle()} + + + + + + + + {!isToday && ( + + {t('calendar20.today')} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 10 : 10, + paddingBottom: 10, + paddingHorizontal: 12, + backgroundColor: '#ffffff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + iconButton: { + padding: 4, + }, + titleContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginLeft: 12, + }, + title: { + fontSize: 18, + fontWeight: '300', + color: '#000000', + fontFamily: 'System', + letterSpacing: -0.5, + }, + chevron: { + marginLeft: 4, + marginTop: 1, + }, + rightActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + todayButton: { + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 16, + borderWidth: 1, + borderColor: '#007AFF', + }, + todayText: { + fontSize: 13, + fontWeight: '500', + color: '#007AFF', + fontFamily: 'System', + }, +}); + +export default React.memo(TopBar); diff --git a/src/components/Calendar20/ViewDrawer.tsx b/src/components/Calendar20/ViewDrawer.tsx new file mode 100644 index 0000000..d5031a8 --- /dev/null +++ b/src/components/Calendar20/ViewDrawer.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + StyleSheet, + Pressable, + ScrollView, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarViewType } from './types'; +import CategoryColorService from './categoryColors'; +import { useTranslation } from 'react-i18next'; + +interface ViewDrawerProps { + visible: boolean; + currentView: CalendarViewType; + categories: any[]; + enabledCategories: Set; + colorService: CategoryColorService; + onViewChange: (view: CalendarViewType) => void; + onCategoryToggle: (categoryName: string) => void; + onShowAll: () => void; + onClose: () => void; +} + +const VIEW_OPTIONS: { key: CalendarViewType; icon: keyof typeof Ionicons.glyphMap; labelKey: string }[] = [ + { key: 'month', icon: 'grid-outline', labelKey: 'calendar20.views.month' }, + { key: 'week', icon: 'calendar-outline', labelKey: 'calendar20.views.week' }, + { key: '3day', icon: 'albums-outline', labelKey: 'calendar20.views.threeDay' }, + { key: 'day', icon: 'today-outline', labelKey: 'calendar20.views.day' }, + { key: 'agenda', icon: 'list-outline', labelKey: 'calendar20.views.agenda' }, +]; + +const ViewDrawer: React.FC = ({ + visible, + currentView, + categories, + enabledCategories, + colorService, + onViewChange, + onCategoryToggle, + onShowAll, + onClose, +}) => { + const { t } = useTranslation(); + + if (!visible) return null; + + return ( + + + e.stopPropagation()}> + + {/* Views section */} + {t('calendar20.drawer.views').toUpperCase()} + {VIEW_OPTIONS.map(opt => { + const isActive = currentView === opt.key; + return ( + onViewChange(opt.key)} + > + + + {t(opt.labelKey)} + + {isActive && ( + + )} + + ); + })} + + {/* Categories section */} + {categories.length > 0 && ( + <> + + {t('calendar20.drawer.categories').toUpperCase()} + + + + {t('calendar20.drawer.showAll')} + + {enabledCategories.size === 0 && ( + + )} + + + {categories.map((cat, i) => { + const name = cat.name || cat.category_name || ''; + const color = colorService.getColor(name); + const key = name.toLowerCase().trim(); + const isEnabled = enabledCategories.size === 0 || enabledCategories.has(key); + + return ( + onCategoryToggle(name)} + > + + {isEnabled && ( + + )} + + + {name} + + + ); + })} + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.3)', + flexDirection: 'row', + }, + drawer: { + width: 280, + backgroundColor: '#ffffff', + paddingVertical: 16, + paddingHorizontal: 16, + shadowColor: '#000', + shadowOffset: { width: 2, height: 0 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '600', + color: '#999999', + fontFamily: 'System', + letterSpacing: 0.5, + marginBottom: 8, + marginTop: 8, + }, + viewOption: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: 8, + marginBottom: 2, + }, + activeViewOption: { + backgroundColor: '#EBF2FF', + }, + viewLabel: { + fontSize: 15, + fontWeight: '400', + color: '#333333', + fontFamily: 'System', + marginLeft: 12, + flex: 1, + }, + activeViewLabel: { + color: '#007AFF', + fontWeight: '500', + }, + checkmark: { + marginLeft: 'auto', + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#e1e5e9', + marginVertical: 12, + }, + showAllButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 8, + marginBottom: 4, + }, + showAllText: { + fontSize: 14, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + activeShowAll: { + color: '#007AFF', + fontWeight: '500', + }, + categoryRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 8, + }, + colorSquare: { + width: 20, + height: 20, + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }, + categoryName: { + fontSize: 14, + fontWeight: '400', + color: '#333333', + fontFamily: 'System', + }, + disabledCategory: { + color: '#cccccc', + }, +}); + +export default React.memo(ViewDrawer); diff --git a/src/components/Calendar20/WeekView.tsx b/src/components/Calendar20/WeekView.tsx new file mode 100644 index 0000000..e225c9e --- /dev/null +++ b/src/components/Calendar20/WeekView.tsx @@ -0,0 +1,391 @@ +import React, { useMemo, useRef, useEffect, useState } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + Dimensions, + PanResponder, +} from 'react-native'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { CalendarTask, OverlapColumn } from './types'; +import TimeBlock from './TimeBlock'; +import EventChip from './EventChip'; +import { useTranslation } from 'react-i18next'; + +dayjs.extend(isoWeek); + +const TIME_LABEL_WIDTH = 44; +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 12) / 7; +const MIN_HOUR_HEIGHT = 30; +const MAX_HOUR_HEIGHT = 120; +const DEFAULT_HOUR_HEIGHT = 50; + +interface WeekViewProps { + currentDate: dayjs.Dayjs; + tasks: CalendarTask[]; + onDatePress: (date: dayjs.Dayjs) => void; + onTaskPress: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + onSwipeLeft: () => void; + onSwipeRight: () => void; +} + +function computeOverlapColumns(tasks: CalendarTask[]): OverlapColumn[] { + if (tasks.length === 0) return []; + const sorted = [...tasks].sort((a, b) => { + const diff = a.startDayjs.valueOf() - b.startDayjs.valueOf(); + return diff !== 0 ? diff : b.durationMinutes - a.durationMinutes; + }); + const columns: OverlapColumn[] = []; + const endTimes: number[] = []; + for (const task of sorted) { + const start = task.startDayjs.valueOf(); + let placed = false; + for (let col = 0; col < endTimes.length; col++) { + if (start >= endTimes[col]) { + endTimes[col] = task.endDayjs.valueOf(); + columns.push({ task, column: col, totalColumns: 0 }); + placed = true; + break; + } + } + if (!placed) { + endTimes.push(task.endDayjs.valueOf()); + columns.push({ task, column: endTimes.length - 1, totalColumns: 0 }); + } + } + for (let i = 0; i < columns.length; i++) { + const e = columns[i]; + const s = e.task.startDayjs.valueOf(); + const en = e.task.endDayjs.valueOf(); + let maxCol = e.column; + for (let j = 0; j < columns.length; j++) { + if (i === j) continue; + const o = columns[j]; + if (o.task.startDayjs.valueOf() < en && o.task.endDayjs.valueOf() > s) { + maxCol = Math.max(maxCol, o.column); + } + } + e.totalColumns = maxCol + 1; + } + return columns; +} + +const WeekView: React.FC = ({ + currentDate, + tasks, + onDatePress, + onTaskPress, + onToggleComplete, + onSwipeLeft, + onSwipeRight, +}) => { + const { t } = useTranslation(); + const scrollRef = useRef(null); + const [hourHeight, setHourHeight] = useState(DEFAULT_HOUR_HEIGHT); + + // Pinch-to-zoom + const initialDistance = useRef(0); + const initialHeight = useRef(DEFAULT_HOUR_HEIGHT); + + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (evt, gs) => { + // 2-finger pinch + if (evt.nativeEvent.touches?.length === 2) return true; + // Horizontal swipe + return Math.abs(gs.dx) > 20 && Math.abs(gs.dy) < 20; + }, + onPanResponderGrant: (evt) => { + if (evt.nativeEvent.touches?.length === 2) { + const t1 = evt.nativeEvent.touches[0]; + const t2 = evt.nativeEvent.touches[1]; + initialDistance.current = Math.hypot(t2.pageX - t1.pageX, t2.pageY - t1.pageY); + initialHeight.current = hourHeight; + } + }, + onPanResponderMove: (evt) => { + if (evt.nativeEvent.touches?.length === 2) { + const t1 = evt.nativeEvent.touches[0]; + const t2 = evt.nativeEvent.touches[1]; + const dist = Math.hypot(t2.pageX - t1.pageX, t2.pageY - t1.pageY); + const scale = dist / (initialDistance.current || 1); + const newHeight = Math.min(MAX_HOUR_HEIGHT, Math.max(MIN_HOUR_HEIGHT, initialHeight.current * scale)); + setHourHeight(newHeight); + } + }, + onPanResponderRelease: (_, gs) => { + if (Math.abs(gs.dx) > 60 && Math.abs(gs.dy) < 40) { + if (gs.dx > 0) onSwipeRight(); + else onSwipeLeft(); + } + }, + }) + ).current; + + // Week days + const weekDays = useMemo(() => { + const startOfWeek = currentDate.startOf('isoWeek'); + return Array.from({ length: 7 }, (_, i) => startOfWeek.add(i, 'day')); + }, [currentDate]); + + // All-day tasks per day + const allDayTasksByDay = useMemo(() => { + return weekDays.map(day => { + return tasks.filter(task => { + if (!task.isAllDay) return false; + return ( + day.isSame(task.startDayjs, 'day') || + day.isSame(task.endDayjs, 'day') || + (day.isAfter(task.startDayjs, 'day') && day.isBefore(task.endDayjs, 'day')) + ); + }); + }); + }, [weekDays, tasks]); + + const hasAllDay = allDayTasksByDay.some(d => d.length > 0); + + // Timed tasks per day + const timedTasksByDay = useMemo(() => { + return weekDays.map(day => { + const dayTasks = tasks.filter(task => { + if (task.isAllDay) return false; + return ( + day.isSame(task.startDayjs, 'day') || + day.isSame(task.endDayjs, 'day') || + (day.isAfter(task.startDayjs, 'day') && day.isBefore(task.endDayjs, 'day')) + ); + }); + return computeOverlapColumns(dayTasks); + }); + }, [weekDays, tasks]); + + // Scroll to current time + useEffect(() => { + const now = dayjs(); + const offset = Math.max(0, (now.hour() - 1) * hourHeight); + setTimeout(() => scrollRef.current?.scrollTo({ y: offset, animated: false }), 100); + }, [currentDate, hourHeight]); + + const now = dayjs(); + const hours = Array.from({ length: 24 }, (_, i) => i); + + return ( + + {/* Day headers */} + + + {weekDays.map((day, i) => { + const isToday = day.isSame(now, 'day'); + return ( + + + {day.format('ddd').toUpperCase()} + + + + {day.date()} + + + + ); + })} + + + {/* All-day strip */} + {hasAllDay && ( + + + {t('calendar20.allDay')} + + {weekDays.map((day, i) => ( + + {allDayTasksByDay[i].map(task => ( + + ))} + + ))} + + )} + + {/* Time grid */} + + + {/* Time labels */} + + {hours.map(hour => ( + + {hour.toString().padStart(2, '0')}:00 + + ))} + + + {/* Day columns */} + {weekDays.map((day, dayIndex) => { + const isToday = day.isSame(now, 'day'); + const currentTimeTop = isToday ? (now.hour() + now.minute() / 60) * hourHeight : -1; + + return ( + + {/* Hour lines */} + {hours.map(hour => ( + + ))} + + {/* Time blocks */} + {timedTasksByDay[dayIndex].map(({ task, column, totalColumns }) => ( + + ))} + + {/* Current time */} + {isToday && currentTimeTop >= 0 && ( + + + + + )} + + ); + })} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + headerRow: { + flexDirection: 'row', + paddingHorizontal: 6, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + dayHeader: { + alignItems: 'center', + }, + dayName: { + fontSize: 11, + fontWeight: '500', + color: '#666666', + fontFamily: 'System', + }, + todayColor: { + color: '#007AFF', + }, + dateCircle: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + todayCircle: { + backgroundColor: '#007AFF', + }, + dateNum: { + fontSize: 14, + fontWeight: '400', + color: '#000000', + fontFamily: 'System', + }, + todayDateNum: { + color: '#ffffff', + fontWeight: '600', + }, + allDayRow: { + flexDirection: 'row', + paddingHorizontal: 6, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + backgroundColor: '#fafafa', + }, + allDayLabel: { + justifyContent: 'center', + }, + allDayLabelText: { + fontSize: 10, + color: '#666666', + fontFamily: 'System', + }, + allDayCell: { + paddingHorizontal: 1, + }, + scrollContainer: { + flex: 1, + }, + gridContainer: { + flexDirection: 'row', + paddingHorizontal: 6, + }, + dayColumn: { + position: 'relative', + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: '#f0f0f0', + }, + hourLine: { + position: 'absolute', + left: 0, + right: 0, + height: StyleSheet.hairlineWidth, + backgroundColor: '#e1e5e9', + }, + timeLabel: { + fontSize: 10, + color: '#999999', + fontFamily: 'System', + marginTop: -5, + }, + currentTimeLine: { + position: 'absolute', + left: -2, + right: 0, + flexDirection: 'row', + alignItems: 'center', + zIndex: 10, + }, + currentTimeDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: '#EA4335', + }, + currentTimeBar: { + flex: 1, + height: 1.5, + backgroundColor: '#EA4335', + }, +}); + +export default React.memo(WeekView); diff --git a/src/components/Calendar20/categoryColors.ts b/src/components/Calendar20/categoryColors.ts new file mode 100644 index 0000000..9dfe23e --- /dev/null +++ b/src/components/Calendar20/categoryColors.ts @@ -0,0 +1,84 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const COLOR_POOL = [ + '#4285F4', // Google Blue + '#34A853', // Green + '#EA4335', // Red + '#A142F4', // Purple + '#F4A125', // Orange + '#00ACC1', // Teal + '#E91E63', // Pink + '#795548', // Brown + '#607D8B', // Blue Grey + '#FF7043', // Deep Orange + '#66BB6A', // Light Green + '#AB47BC', // Medium Purple +]; + +const STORAGE_KEY = '@calendar20_category_colors'; + +class CategoryColorService { + private static instance: CategoryColorService; + private colorMap: Record = {}; + private loaded = false; + + static getInstance(): CategoryColorService { + if (!CategoryColorService.instance) { + CategoryColorService.instance = new CategoryColorService(); + } + return CategoryColorService.instance; + } + + async load(): Promise { + if (this.loaded) return; + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + if (stored) { + this.colorMap = JSON.parse(stored); + } + } catch { + this.colorMap = {}; + } + this.loaded = true; + } + + private async save(): Promise { + try { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(this.colorMap)); + } catch { + // silent fail + } + } + + getColor(categoryName: string): string { + if (!categoryName) return COLOR_POOL[0]; + + const key = categoryName.toLowerCase().trim(); + if (this.colorMap[key]) { + return this.colorMap[key]; + } + + // Assign next available color from pool + const usedColors = new Set(Object.values(this.colorMap)); + let color = COLOR_POOL.find(c => !usedColors.has(c)); + if (!color) { + // All colors used, cycle based on hash + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; + } + color = COLOR_POOL[Math.abs(hash) % COLOR_POOL.length]; + } + + this.colorMap[key] = color; + this.save(); + return color; + } + + assignColors(categoryNames: string[]): void { + categoryNames.forEach(name => this.getColor(name)); + } +} + +export default CategoryColorService; +export { COLOR_POOL }; diff --git a/src/components/Calendar20/types.ts b/src/components/Calendar20/types.ts new file mode 100644 index 0000000..9acad4f --- /dev/null +++ b/src/components/Calendar20/types.ts @@ -0,0 +1,42 @@ +import { Task } from '../../services/taskService'; +import dayjs from 'dayjs'; + +export type CalendarViewType = 'month' | 'week' | '3day' | 'day' | 'agenda'; + +export interface CalendarTask extends Task { + displayColor: string; + startDayjs: dayjs.Dayjs; + endDayjs: dayjs.Dayjs; + durationMinutes: number; + isMultiDay: boolean; + isAllDay: boolean; +} + +export interface DayData { + date: dayjs.Dayjs; + dateString: string; + isCurrentMonth: boolean; + isToday: boolean; + tasks: CalendarTask[]; +} + +export interface WeekData { + days: DayData[]; +} + +export interface TimeSlot { + hour: number; + tasks: CalendarTask[]; +} + +export interface OverlapColumn { + task: CalendarTask; + column: number; + totalColumns: number; +} + +export interface CategoryColor { + categoryName: string; + categoryId?: string | number; + color: string; +} diff --git a/src/locales/en.json b/src/locales/en.json index b22e19c..29a6435 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -5,6 +5,7 @@ "categories": "Categories", "notes": "Notes", "calendar": "Calendar", + "calendar20": "Calendar 2.0", "statistics": "Statistics" }, "screens": { @@ -434,6 +435,33 @@ "sunday": "Sun" } }, + "calendar20": { + "title": "Calendar 2.0", + "views": { + "month": "Month", + "week": "Week", + "threeDay": "3 Days", + "day": "Day", + "agenda": "Agenda" + }, + "today": "Today", + "allDay": "All day", + "noEvents": "No events", + "more": "+{{count}} more", + "search": { + "placeholder": "Search tasks...", + "noResults": "No tasks found" + }, + "fab": { + "newTask": "New Task", + "newEvent": "New Event" + }, + "drawer": { + "views": "Views", + "categories": "Categories", + "showAll": "Show all" + } + }, "statistics": { "title": "Statistics", "overview": "Overview", diff --git a/src/locales/it.json b/src/locales/it.json index 3f9e87c..013ce36 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -5,6 +5,7 @@ "categories": "Categorie", "notes": "Note", "calendar": "Calendario", + "calendar20": "Calendario 2.0", "statistics": "Statistiche" }, "screens": { @@ -434,6 +435,33 @@ "sunday": "Dom" } }, + "calendar20": { + "title": "Calendario 2.0", + "views": { + "month": "Mese", + "week": "Settimana", + "threeDay": "3 Giorni", + "day": "Giorno", + "agenda": "Agenda" + }, + "today": "Oggi", + "allDay": "Tutto il giorno", + "noEvents": "Nessun evento", + "more": "+{{count}} altri", + "search": { + "placeholder": "Cerca task...", + "noResults": "Nessun task trovato" + }, + "fab": { + "newTask": "Nuovo Task", + "newEvent": "Nuovo Evento" + }, + "drawer": { + "views": "Viste", + "categories": "Categorie", + "showAll": "Mostra tutto" + } + }, "statistics": { "title": "Statistiche", "overview": "Panoramica", diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 52e526a..c8e55a7 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -28,6 +28,7 @@ import LanguageScreen from "./screens/Language"; import VoiceSettingsScreen from "./screens/VoiceSettings"; import GoogleCalendarScreen from "./screens/GoogleCalendar"; import CalendarScreen from "./screens/Calendar"; +import Calendar20Screen from "./screens/Calendar20"; import NotificationDebugScreen from "./screens/NotificationDebug"; import BugReportScreen from "./screens/BugReport"; //import StatisticsScreen from "./screens/Statistics"; @@ -77,6 +78,7 @@ export type TabParamList = { Categories: undefined; Notes: undefined; Calendar: undefined; + Calendar20: undefined; Statistics: undefined; }; @@ -109,6 +111,9 @@ function HomeTabs() { case "Calendar": iconName = focused ? "calendar" : "calendar-outline"; break; + case "Calendar20": + iconName = focused ? "today" : "today-outline"; + break; case "Statistics": iconName = focused ? "stats-chart" : "stats-chart-outline"; break; @@ -144,6 +149,11 @@ function HomeTabs() { component={CalendarScreen} options={{ title: t('navigation.tabs.calendar') }} /> + {/* + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, +}); From cb0bdedbb826f2e1c4905f79b635e0cdacb7d0fc Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 11:57:35 +0100 Subject: [PATCH 19/29] chore: add auto-claude entries to .gitignore --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index ff572ec..0841b67 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,11 @@ docs/ # Auto Claude data directory .auto-claude/ + +# Auto Claude generated files +.auto-claude-security.json +.auto-claude-status +.claude_settings.json +.worktrees/ +.security-key +logs/security/ From 8430ca068c87ec46a1dbaedd4c70fc57a5f21d5e Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 11:58:53 +0100 Subject: [PATCH 20/29] edit: modficato il bottone per agigugnere un task in calendar2.0 --- src/components/Calendar20/FABMenu.tsx | 153 ++------------------------ 1 file changed, 10 insertions(+), 143 deletions(-) diff --git a/src/components/Calendar20/FABMenu.tsx b/src/components/Calendar20/FABMenu.tsx index 66892ec..b67c08a 100644 --- a/src/components/Calendar20/FABMenu.tsx +++ b/src/components/Calendar20/FABMenu.tsx @@ -1,123 +1,31 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React from 'react'; import { - View, - Text, TouchableOpacity, StyleSheet, - Animated, - Pressable, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useTranslation } from 'react-i18next'; interface FABMenuProps { onNewTask: () => void; } const FABMenu: React.FC = ({ onNewTask }) => { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const animValue = useRef(new Animated.Value(0)).current; - const rotateValue = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.spring(animValue, { - toValue: expanded ? 1 : 0, - useNativeDriver: true, - tension: 50, - friction: 8, - }), - Animated.spring(rotateValue, { - toValue: expanded ? 1 : 0, - useNativeDriver: true, - tension: 50, - friction: 8, - }), - ]).start(); - }, [expanded, animValue, rotateValue]); - - const rotate = rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '45deg'], - }); - - const handleNewTask = () => { - setExpanded(false); - onNewTask(); - }; - return ( - <> - {/* Backdrop when expanded */} - {expanded && ( - setExpanded(false)} /> - )} - - - {/* Task option */} - - - - - - - {t('calendar20.fab.newTask')} - - - - - {/* Main FAB */} - setExpanded(!expanded)} - activeOpacity={0.8} - > - - - - - - + + + ); }; const styles = StyleSheet.create({ - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.15)', - zIndex: 99, - }, - container: { + fab: { position: 'absolute', bottom: 24, right: 20, - alignItems: 'flex-end', - zIndex: 100, - }, - fab: { width: 56, height: 56, borderRadius: 28, @@ -129,48 +37,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.2, shadowRadius: 8, elevation: 6, - }, - optionContainer: { - position: 'absolute', - bottom: 0, - right: 0, - flexDirection: 'row', - alignItems: 'center', - }, - optionButton: { - flexDirection: 'row', - alignItems: 'center', - }, - optionLabel: { - backgroundColor: '#ffffff', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - marginRight: 8, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - optionText: { - fontSize: 13, - fontWeight: '500', - color: '#333333', - fontFamily: 'System', - }, - optionIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#007AFF', - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.15, - shadowRadius: 4, - elevation: 3, + zIndex: 100, }, }); From f615e1f494d140a1714b82a5e66c31e6fcc28f3a Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 12:23:07 +0100 Subject: [PATCH 21/29] feat: modificato lo stile del calendar2.0 --- src/components/Calendar20/AgendaView.tsx | 8 ++++---- src/components/Calendar20/DayView.tsx | 4 ++-- src/components/Calendar20/EventChip.tsx | 4 ++-- src/components/Calendar20/FABMenu.tsx | 6 +++--- src/components/Calendar20/MiniCalendar.tsx | 16 ++++++++-------- src/components/Calendar20/MonthView.tsx | 4 ++-- src/components/Calendar20/SearchOverlay.tsx | 8 ++++---- src/components/Calendar20/ThreeDayView.tsx | 8 ++++---- src/components/Calendar20/TimeBlock.tsx | 4 ++-- src/components/Calendar20/TopBar.tsx | 4 ++-- src/components/Calendar20/ViewDrawer.tsx | 20 +++++++++++--------- src/components/Calendar20/WeekView.tsx | 8 ++++---- src/components/Calendar20/categoryColors.ts | 2 +- 13 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx index cd3cfb3..a91f32c 100644 --- a/src/components/Calendar20/AgendaView.tsx +++ b/src/components/Calendar20/AgendaView.tsx @@ -156,7 +156,7 @@ const AgendaView: React.FC = ({ onEndReachedThreshold={0.5} ListFooterComponent={ - + Load more } @@ -179,7 +179,7 @@ const styles = StyleSheet.create({ borderBottomColor: '#e1e5e9', }, todayHeader: { - backgroundColor: '#EBF2FF', + backgroundColor: '#f0f0f0', }, sectionTitle: { fontSize: 14, @@ -189,7 +189,7 @@ const styles = StyleSheet.create({ letterSpacing: -0.3, }, todayTitle: { - color: '#007AFF', + color: '#000000', fontWeight: '600', }, taskRow: { @@ -257,7 +257,7 @@ const styles = StyleSheet.create({ }, loadMoreText: { fontSize: 14, - color: '#007AFF', + color: '#000000', fontWeight: '500', fontFamily: 'System', }, diff --git a/src/components/Calendar20/DayView.tsx b/src/components/Calendar20/DayView.tsx index 5e9878b..daab55d 100644 --- a/src/components/Calendar20/DayView.tsx +++ b/src/components/Calendar20/DayView.tsx @@ -277,12 +277,12 @@ const styles = StyleSheet.create({ width: 8, height: 8, borderRadius: 4, - backgroundColor: '#EA4335', + backgroundColor: '#000000', }, currentTimeBar: { flex: 1, height: 1.5, - backgroundColor: '#EA4335', + backgroundColor: '#000000', }, }); diff --git a/src/components/Calendar20/EventChip.tsx b/src/components/Calendar20/EventChip.tsx index 6a22f92..a0d17a4 100644 --- a/src/components/Calendar20/EventChip.tsx +++ b/src/components/Calendar20/EventChip.tsx @@ -11,7 +11,7 @@ interface EventChipProps { } const EventChip: React.FC = ({ task, onPress, isSpanning, isStart = true, isEnd = true }) => { - const bgColor = task.displayColor || '#4285F4'; + const bgColor = task.displayColor || '#007AFF'; const isCompleted = task.status?.toLowerCase() === 'completato' || task.status?.toLowerCase() === 'completed'; return ( @@ -37,7 +37,7 @@ const styles = StyleSheet.create({ chip: { paddingHorizontal: 4, paddingVertical: 1, - borderRadius: 3, + borderRadius: 4, marginBottom: 1, minHeight: 16, justifyContent: 'center', diff --git a/src/components/Calendar20/FABMenu.tsx b/src/components/Calendar20/FABMenu.tsx index b67c08a..ed70f2c 100644 --- a/src/components/Calendar20/FABMenu.tsx +++ b/src/components/Calendar20/FABMenu.tsx @@ -34,9 +34,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 6, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, zIndex: 100, }, }); diff --git a/src/components/Calendar20/MiniCalendar.tsx b/src/components/Calendar20/MiniCalendar.tsx index af3f0f8..64ee5a4 100644 --- a/src/components/Calendar20/MiniCalendar.tsx +++ b/src/components/Calendar20/MiniCalendar.tsx @@ -132,21 +132,21 @@ const MiniCalendar: React.FC = ({ const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.3)', + backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-start', paddingTop: 80, alignItems: 'center', }, container: { backgroundColor: '#ffffff', - borderRadius: 16, + borderRadius: 24, padding: 16, width: 320, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, + shadowOpacity: 0.08, shadowRadius: 12, - elevation: 8, + elevation: 3, }, monthNav: { flexDirection: 'row', @@ -183,12 +183,12 @@ const styles = StyleSheet.create({ paddingVertical: 6, }, selectedCell: { - backgroundColor: '#007AFF', + backgroundColor: '#000000', borderRadius: 14, }, todayCell: { borderWidth: 1, - borderColor: '#007AFF', + borderColor: '#000000', borderRadius: 14, }, dayText: { @@ -205,7 +205,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, todayText: { - color: '#007AFF', + color: '#000000', fontWeight: '600', }, todayButton: { @@ -216,7 +216,7 @@ const styles = StyleSheet.create({ todayButtonText: { fontSize: 14, fontWeight: '500', - color: '#007AFF', + color: '#000000', fontFamily: 'System', }, }); diff --git a/src/components/Calendar20/MonthView.tsx b/src/components/Calendar20/MonthView.tsx index 1b9aa18..7483326 100644 --- a/src/components/Calendar20/MonthView.tsx +++ b/src/components/Calendar20/MonthView.tsx @@ -191,7 +191,7 @@ const styles = StyleSheet.create({ }, weekRow: { flexDirection: 'row', - minHeight: 72, + flex: 1, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0', }, @@ -210,7 +210,7 @@ const styles = StyleSheet.create({ marginBottom: 2, }, todayCircle: { - backgroundColor: '#007AFF', + backgroundColor: '#000000', }, dateText: { fontSize: 13, diff --git a/src/components/Calendar20/SearchOverlay.tsx b/src/components/Calendar20/SearchOverlay.tsx index f13473d..c460335 100644 --- a/src/components/Calendar20/SearchOverlay.tsx +++ b/src/components/Calendar20/SearchOverlay.tsx @@ -124,14 +124,14 @@ const SearchOverlay: React.FC = ({ const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.3)', + backgroundColor: 'rgba(0,0,0,0.4)', }, container: { flex: 1, backgroundColor: '#ffffff', marginTop: 40, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, overflow: 'hidden', }, searchBar: { @@ -158,7 +158,7 @@ const styles = StyleSheet.create({ cancelText: { fontSize: 14, fontWeight: '500', - color: '#007AFF', + color: '#000000', fontFamily: 'System', }, resultsList: { diff --git a/src/components/Calendar20/ThreeDayView.tsx b/src/components/Calendar20/ThreeDayView.tsx index 65d6865..260b9af 100644 --- a/src/components/Calendar20/ThreeDayView.tsx +++ b/src/components/Calendar20/ThreeDayView.tsx @@ -247,7 +247,7 @@ const styles = StyleSheet.create({ color: '#666666', fontFamily: 'System', }, - todayColor: { color: '#007AFF' }, + todayColor: { color: '#000000' }, dateCircle: { width: 28, height: 28, @@ -256,7 +256,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginTop: 2, }, - todayCircle: { backgroundColor: '#007AFF' }, + todayCircle: { backgroundColor: '#000000' }, dateNum: { fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System' }, todayDateNum: { color: '#ffffff', fontWeight: '600' }, allDayRow: { @@ -292,8 +292,8 @@ const styles = StyleSheet.create({ alignItems: 'center', zIndex: 10, }, - currentTimeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#EA4335' }, - currentTimeBar: { flex: 1, height: 1.5, backgroundColor: '#EA4335' }, + currentTimeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#000000' }, + currentTimeBar: { flex: 1, height: 1.5, backgroundColor: '#000000' }, }); export default React.memo(ThreeDayView); diff --git a/src/components/Calendar20/TimeBlock.tsx b/src/components/Calendar20/TimeBlock.tsx index 01f6ec0..bc73a90 100644 --- a/src/components/Calendar20/TimeBlock.tsx +++ b/src/components/Calendar20/TimeBlock.tsx @@ -29,7 +29,7 @@ const TimeBlock: React.FC = ({ const left = column * (columnWidth / totalColumns) + 1; const isCompleted = task.status?.toLowerCase() === 'completato' || task.status?.toLowerCase() === 'completed'; - const bgColor = task.displayColor || '#4285F4'; + const bgColor = task.displayColor || '#007AFF'; const startTime = task.startDayjs.format('HH:mm'); const endTime = task.endDayjs.format('HH:mm'); @@ -82,7 +82,7 @@ const TimeBlock: React.FC = ({ const styles = StyleSheet.create({ block: { position: 'absolute', - borderRadius: 4, + borderRadius: 6, paddingHorizontal: 4, paddingVertical: 2, overflow: 'hidden', diff --git a/src/components/Calendar20/TopBar.tsx b/src/components/Calendar20/TopBar.tsx index 939fa3c..ac910ad 100644 --- a/src/components/Calendar20/TopBar.tsx +++ b/src/components/Calendar20/TopBar.tsx @@ -114,12 +114,12 @@ const styles = StyleSheet.create({ paddingVertical: 5, borderRadius: 16, borderWidth: 1, - borderColor: '#007AFF', + borderColor: '#000000', }, todayText: { fontSize: 13, fontWeight: '500', - color: '#007AFF', + color: '#000000', fontFamily: 'System', }, }); diff --git a/src/components/Calendar20/ViewDrawer.tsx b/src/components/Calendar20/ViewDrawer.tsx index d5031a8..32ef60a 100644 --- a/src/components/Calendar20/ViewDrawer.tsx +++ b/src/components/Calendar20/ViewDrawer.tsx @@ -66,13 +66,13 @@ const ViewDrawer: React.FC = ({ {t(opt.labelKey)} {isActive && ( - + )} ); @@ -89,7 +89,7 @@ const ViewDrawer: React.FC = ({ {t('calendar20.drawer.showAll')} {enabledCategories.size === 0 && ( - + )} @@ -128,7 +128,7 @@ const ViewDrawer: React.FC = ({ const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.3)', + backgroundColor: 'rgba(0,0,0,0.4)', flexDirection: 'row', }, drawer: { @@ -138,9 +138,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, shadowColor: '#000', shadowOffset: { width: 2, height: 0 }, - shadowOpacity: 0.15, + shadowOpacity: 0.08, shadowRadius: 12, - elevation: 8, + elevation: 3, + borderTopRightRadius: 24, + borderBottomRightRadius: 24, }, sectionTitle: { fontSize: 12, @@ -160,7 +162,7 @@ const styles = StyleSheet.create({ marginBottom: 2, }, activeViewOption: { - backgroundColor: '#EBF2FF', + backgroundColor: '#f0f0f0', }, viewLabel: { fontSize: 15, @@ -171,7 +173,7 @@ const styles = StyleSheet.create({ flex: 1, }, activeViewLabel: { - color: '#007AFF', + color: '#000000', fontWeight: '500', }, checkmark: { @@ -197,7 +199,7 @@ const styles = StyleSheet.create({ fontFamily: 'System', }, activeShowAll: { - color: '#007AFF', + color: '#000000', fontWeight: '500', }, categoryRow: { diff --git a/src/components/Calendar20/WeekView.tsx b/src/components/Calendar20/WeekView.tsx index e225c9e..c941e3c 100644 --- a/src/components/Calendar20/WeekView.tsx +++ b/src/components/Calendar20/WeekView.tsx @@ -300,7 +300,7 @@ const styles = StyleSheet.create({ fontFamily: 'System', }, todayColor: { - color: '#007AFF', + color: '#000000', }, dateCircle: { width: 24, @@ -311,7 +311,7 @@ const styles = StyleSheet.create({ marginTop: 2, }, todayCircle: { - backgroundColor: '#007AFF', + backgroundColor: '#000000', }, dateNum: { fontSize: 14, @@ -379,12 +379,12 @@ const styles = StyleSheet.create({ width: 6, height: 6, borderRadius: 3, - backgroundColor: '#EA4335', + backgroundColor: '#000000', }, currentTimeBar: { flex: 1, height: 1.5, - backgroundColor: '#EA4335', + backgroundColor: '#000000', }, }); diff --git a/src/components/Calendar20/categoryColors.ts b/src/components/Calendar20/categoryColors.ts index 9dfe23e..c89ff2a 100644 --- a/src/components/Calendar20/categoryColors.ts +++ b/src/components/Calendar20/categoryColors.ts @@ -1,7 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; const COLOR_POOL = [ - '#4285F4', // Google Blue + '#007AFF', // App Blue '#34A853', // Green '#EA4335', // Red '#A142F4', // Purple From 5e91d207c98a1a0acef90bfb94bbc6940ab44f8f Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 12:33:30 +0100 Subject: [PATCH 22/29] feat: migliorato lo stile del calendar2.0 con padding e distranzxa --- src/components/Calendar20/AgendaView.tsx | 42 ++++++++++----------- src/components/Calendar20/DayView.tsx | 14 +++---- src/components/Calendar20/EventChip.tsx | 12 +++--- src/components/Calendar20/MiniCalendar.tsx | 28 +++++++------- src/components/Calendar20/MonthView.tsx | 16 ++++---- src/components/Calendar20/SearchOverlay.tsx | 32 ++++++++-------- src/components/Calendar20/ThreeDayView.tsx | 24 ++++++------ src/components/Calendar20/TimeBlock.tsx | 14 +++---- src/components/Calendar20/TopBar.tsx | 20 +++++----- src/components/Calendar20/ViewDrawer.tsx | 40 ++++++++++---------- src/components/Calendar20/WeekView.tsx | 22 +++++------ 11 files changed, 132 insertions(+), 132 deletions(-) diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx index a91f32c..a9356b2 100644 --- a/src/components/Calendar20/AgendaView.tsx +++ b/src/components/Calendar20/AgendaView.tsx @@ -172,8 +172,8 @@ const styles = StyleSheet.create({ paddingBottom: 80, }, sectionHeader: { - paddingHorizontal: 16, - paddingVertical: 8, + paddingHorizontal: 20, + paddingVertical: 10, backgroundColor: '#f8f9fa', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', @@ -182,39 +182,39 @@ const styles = StyleSheet.create({ backgroundColor: '#f0f0f0', }, sectionTitle: { - fontSize: 14, - fontWeight: '500', + fontSize: 15, + fontWeight: '400', color: '#333333', fontFamily: 'System', letterSpacing: -0.3, }, todayTitle: { color: '#000000', - fontWeight: '600', + fontWeight: '500', }, taskRow: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, + paddingHorizontal: 20, + paddingVertical: 14, backgroundColor: '#ffffff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0', }, checkbox: { - marginRight: 8, + marginRight: 10, }, colorDot: { - width: 8, - height: 8, - borderRadius: 4, - marginRight: 10, + width: 10, + height: 10, + borderRadius: 5, + marginRight: 12, }, taskContent: { flex: 1, }, taskTitle: { - fontSize: 15, + fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -224,26 +224,26 @@ const styles = StyleSheet.create({ color: '#999999', }, taskTime: { - fontSize: 12, + fontSize: 13, color: '#666666', fontFamily: 'System', marginTop: 2, }, categoryBadge: { - fontSize: 11, + fontSize: 12, fontWeight: '500', fontFamily: 'System', maxWidth: 80, }, emptyDay: { - paddingHorizontal: 16, - paddingVertical: 12, + paddingHorizontal: 20, + paddingVertical: 14, backgroundColor: '#ffffff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0', }, emptyText: { - fontSize: 14, + fontSize: 15, color: '#cccccc', fontFamily: 'System', fontStyle: 'italic', @@ -252,11 +252,11 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 16, - gap: 6, + paddingVertical: 20, + gap: 8, }, loadMoreText: { - fontSize: 14, + fontSize: 15, color: '#000000', fontWeight: '500', fontFamily: 'System', diff --git a/src/components/Calendar20/DayView.tsx b/src/components/Calendar20/DayView.tsx index daab55d..8dd29bb 100644 --- a/src/components/Calendar20/DayView.tsx +++ b/src/components/Calendar20/DayView.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; const HOUR_HEIGHT = 60; const TIME_LABEL_WIDTH = 48; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const COLUMN_WIDTH = SCREEN_WIDTH - TIME_LABEL_WIDTH - 12; +const COLUMN_WIDTH = SCREEN_WIDTH - TIME_LABEL_WIDTH - 16; interface DayViewProps { currentDate: dayjs.Dayjs; @@ -214,16 +214,16 @@ const styles = StyleSheet.create({ allDayContainer: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 12, - paddingVertical: 6, + paddingHorizontal: 16, + paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', backgroundColor: '#fafafa', }, allDayLabel: { width: TIME_LABEL_WIDTH - 12, - fontSize: 10, - fontWeight: '500', + fontSize: 11, + fontWeight: '400', color: '#666666', fontFamily: 'System', }, @@ -231,14 +231,14 @@ const styles = StyleSheet.create({ flex: 1, flexDirection: 'row', flexWrap: 'wrap', - gap: 4, + gap: 6, }, scrollContainer: { flex: 1, }, gridContainer: { flexDirection: 'row', - paddingHorizontal: 6, + paddingHorizontal: 8, }, timeColumn: { width: TIME_LABEL_WIDTH, diff --git a/src/components/Calendar20/EventChip.tsx b/src/components/Calendar20/EventChip.tsx index a0d17a4..4a48b83 100644 --- a/src/components/Calendar20/EventChip.tsx +++ b/src/components/Calendar20/EventChip.tsx @@ -35,11 +35,11 @@ const EventChip: React.FC = ({ task, onPress, isSpanning, isStar const styles = StyleSheet.create({ chip: { - paddingHorizontal: 4, - paddingVertical: 1, - borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, marginBottom: 1, - minHeight: 16, + minHeight: 18, justifyContent: 'center', }, spanningLeft: { @@ -54,8 +54,8 @@ const styles = StyleSheet.create({ }, chipText: { color: '#ffffff', - fontSize: 10, - fontWeight: '500', + fontSize: 11, + fontWeight: '400', fontFamily: 'System', }, completed: { diff --git a/src/components/Calendar20/MiniCalendar.tsx b/src/components/Calendar20/MiniCalendar.tsx index 64ee5a4..8c7ac5c 100644 --- a/src/components/Calendar20/MiniCalendar.tsx +++ b/src/components/Calendar20/MiniCalendar.tsx @@ -140,7 +140,7 @@ const styles = StyleSheet.create({ container: { backgroundColor: '#ffffff', borderRadius: 24, - padding: 16, + padding: 20, width: 320, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, @@ -152,24 +152,24 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 12, + marginBottom: 16, }, monthTitle: { - fontSize: 16, - fontWeight: '500', + fontSize: 18, + fontWeight: '300', color: '#000000', fontFamily: 'System', - letterSpacing: -0.3, + letterSpacing: -0.5, }, headerRow: { flexDirection: 'row', - marginBottom: 4, + marginBottom: 8, }, headerText: { flex: 1, textAlign: 'center', - fontSize: 11, - fontWeight: '500', + fontSize: 12, + fontWeight: '400', color: '#999999', fontFamily: 'System', }, @@ -180,7 +180,7 @@ const styles = StyleSheet.create({ flex: 1, alignItems: 'center', justifyContent: 'center', - paddingVertical: 6, + paddingVertical: 8, }, selectedCell: { backgroundColor: '#000000', @@ -192,7 +192,7 @@ const styles = StyleSheet.create({ borderRadius: 14, }, dayText: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -209,13 +209,13 @@ const styles = StyleSheet.create({ fontWeight: '600', }, todayButton: { - marginTop: 12, + marginTop: 16, alignItems: 'center', - paddingVertical: 8, + paddingVertical: 10, }, todayButtonText: { - fontSize: 14, - fontWeight: '500', + fontSize: 15, + fontWeight: '400', color: '#000000', fontFamily: 'System', }, diff --git a/src/components/Calendar20/MonthView.tsx b/src/components/Calendar20/MonthView.tsx index 7483326..cb1de51 100644 --- a/src/components/Calendar20/MonthView.tsx +++ b/src/components/Calendar20/MonthView.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; dayjs.extend(isoWeek); const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const DAY_WIDTH = (SCREEN_WIDTH - 24) / 7; +const DAY_WIDTH = (SCREEN_WIDTH - 32) / 7; const MAX_CHIPS = 3; interface MonthViewProps { @@ -168,11 +168,11 @@ const MonthView: React.FC = ({ const styles = StyleSheet.create({ container: { flex: 1, - paddingHorizontal: 12, + paddingHorizontal: 16, }, headerRow: { flexDirection: 'row', - paddingVertical: 8, + paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, @@ -201,9 +201,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 1, }, dateCircle: { - width: 24, - height: 24, - borderRadius: 12, + width: 28, + height: 28, + borderRadius: 14, alignItems: 'center', justifyContent: 'center', alignSelf: 'center', @@ -213,7 +213,7 @@ const styles = StyleSheet.create({ backgroundColor: '#000000', }, dateText: { - fontSize: 13, + fontSize: 14, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -232,7 +232,7 @@ const styles = StyleSheet.create({ flex: 1, }, moreText: { - fontSize: 10, + fontSize: 11, color: '#666666', fontFamily: 'System', textAlign: 'center', diff --git a/src/components/Calendar20/SearchOverlay.tsx b/src/components/Calendar20/SearchOverlay.tsx index c460335..a529ec7 100644 --- a/src/components/Calendar20/SearchOverlay.tsx +++ b/src/components/Calendar20/SearchOverlay.tsx @@ -129,7 +129,7 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#ffffff', - marginTop: 40, + marginTop: 48, borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', @@ -137,15 +137,15 @@ const styles = StyleSheet.create({ searchBar: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, + paddingHorizontal: 20, + paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', backgroundColor: '#fafafa', }, input: { flex: 1, - fontSize: 16, + fontSize: 17, fontFamily: 'System', color: '#000000', marginLeft: 8, @@ -156,8 +156,8 @@ const styles = StyleSheet.create({ marginLeft: 8, }, cancelText: { - fontSize: 14, - fontWeight: '500', + fontSize: 15, + fontWeight: '400', color: '#000000', fontFamily: 'System', }, @@ -167,34 +167,34 @@ const styles = StyleSheet.create({ resultRow: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, + paddingHorizontal: 20, + paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0', }, colorDot: { - width: 8, - height: 8, - borderRadius: 4, - marginRight: 12, + width: 10, + height: 10, + borderRadius: 5, + marginRight: 14, }, resultContent: { flex: 1, }, resultTitle: { - fontSize: 15, + fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System', }, resultDate: { - fontSize: 12, + fontSize: 13, color: '#666666', fontFamily: 'System', marginTop: 2, }, resultCategory: { - fontSize: 11, + fontSize: 12, fontWeight: '500', fontFamily: 'System', maxWidth: 80, @@ -205,7 +205,7 @@ const styles = StyleSheet.create({ paddingTop: 60, }, noResultsText: { - fontSize: 15, + fontSize: 16, color: '#999999', fontFamily: 'System', marginTop: 12, diff --git a/src/components/Calendar20/ThreeDayView.tsx b/src/components/Calendar20/ThreeDayView.tsx index 260b9af..9d092a3 100644 --- a/src/components/Calendar20/ThreeDayView.tsx +++ b/src/components/Calendar20/ThreeDayView.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; const HOUR_HEIGHT = 60; const TIME_LABEL_WIDTH = 44; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 12) / 3; +const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 16) / 3; interface ThreeDayViewProps { currentDate: dayjs.Dayjs; @@ -235,15 +235,15 @@ const styles = StyleSheet.create({ container: { flex: 1 }, headerRow: { flexDirection: 'row', - paddingHorizontal: 6, - paddingVertical: 4, + paddingHorizontal: 8, + paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, dayHeader: { alignItems: 'center' }, dayName: { - fontSize: 11, - fontWeight: '500', + fontSize: 12, + fontWeight: '400', color: '#666666', fontFamily: 'System', }, @@ -254,23 +254,23 @@ const styles = StyleSheet.create({ borderRadius: 14, alignItems: 'center', justifyContent: 'center', - marginTop: 2, + marginTop: 3, }, todayCircle: { backgroundColor: '#000000' }, - dateNum: { fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System' }, + dateNum: { fontSize: 17, fontWeight: '400', color: '#000000', fontFamily: 'System' }, todayDateNum: { color: '#ffffff', fontWeight: '600' }, allDayRow: { flexDirection: 'row', - paddingHorizontal: 6, - paddingVertical: 4, + paddingHorizontal: 8, + paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', backgroundColor: '#fafafa', }, - allDayLabelText: { fontSize: 10, color: '#666666', fontFamily: 'System' }, + allDayLabelText: { fontSize: 11, color: '#666666', fontFamily: 'System' }, allDayCell: { paddingHorizontal: 1 }, scrollContainer: { flex: 1 }, - gridContainer: { flexDirection: 'row', paddingHorizontal: 6 }, + gridContainer: { flexDirection: 'row', paddingHorizontal: 8 }, dayColumn: { position: 'relative', borderLeftWidth: StyleSheet.hairlineWidth, @@ -283,7 +283,7 @@ const styles = StyleSheet.create({ height: StyleSheet.hairlineWidth, backgroundColor: '#e1e5e9', }, - timeLabel: { fontSize: 10, color: '#999999', fontFamily: 'System', marginTop: -5 }, + timeLabel: { fontSize: 11, color: '#999999', fontFamily: 'System', marginTop: -5 }, currentTimeLine: { position: 'absolute', left: -2, diff --git a/src/components/Calendar20/TimeBlock.tsx b/src/components/Calendar20/TimeBlock.tsx index bc73a90..90ae2af 100644 --- a/src/components/Calendar20/TimeBlock.tsx +++ b/src/components/Calendar20/TimeBlock.tsx @@ -82,9 +82,9 @@ const TimeBlock: React.FC = ({ const styles = StyleSheet.create({ block: { position: 'absolute', - borderRadius: 6, - paddingHorizontal: 4, - paddingVertical: 2, + borderRadius: 8, + paddingHorizontal: 6, + paddingVertical: 3, overflow: 'hidden', borderLeftWidth: 3, borderLeftColor: 'rgba(0,0,0,0.15)', @@ -100,11 +100,11 @@ const styles = StyleSheet.create({ alignItems: 'center', }, checkboxArea: { - marginRight: 3, + marginRight: 4, }, title: { - fontSize: 11, - fontWeight: '500', + fontSize: 12, + fontWeight: '400', color: '#ffffff', fontFamily: 'System', flex: 1, @@ -113,7 +113,7 @@ const styles = StyleSheet.create({ textDecorationLine: 'line-through', }, time: { - fontSize: 10, + fontSize: 11, color: 'rgba(255,255,255,0.8)', fontFamily: 'System', marginTop: 1, diff --git a/src/components/Calendar20/TopBar.tsx b/src/components/Calendar20/TopBar.tsx index ac910ad..41620b5 100644 --- a/src/components/Calendar20/TopBar.tsx +++ b/src/components/Calendar20/TopBar.tsx @@ -78,8 +78,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 10 : 10, - paddingBottom: 10, - paddingHorizontal: 12, + paddingBottom: 12, + paddingHorizontal: 16, backgroundColor: '#ffffff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', @@ -94,11 +94,11 @@ const styles = StyleSheet.create({ marginLeft: 12, }, title: { - fontSize: 18, - fontWeight: '300', + fontSize: 20, + fontWeight: '200', color: '#000000', fontFamily: 'System', - letterSpacing: -0.5, + letterSpacing: -0.8, }, chevron: { marginLeft: 4, @@ -110,15 +110,15 @@ const styles = StyleSheet.create({ gap: 8, }, todayButton: { - paddingHorizontal: 12, - paddingVertical: 5, - borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 6, + borderRadius: 20, borderWidth: 1, borderColor: '#000000', }, todayText: { - fontSize: 13, - fontWeight: '500', + fontSize: 14, + fontWeight: '400', color: '#000000', fontFamily: 'System', }, diff --git a/src/components/Calendar20/ViewDrawer.tsx b/src/components/Calendar20/ViewDrawer.tsx index 32ef60a..81e3702 100644 --- a/src/components/Calendar20/ViewDrawer.tsx +++ b/src/components/Calendar20/ViewDrawer.tsx @@ -134,8 +134,8 @@ const styles = StyleSheet.create({ drawer: { width: 280, backgroundColor: '#ffffff', - paddingVertical: 16, - paddingHorizontal: 16, + paddingVertical: 20, + paddingHorizontal: 20, shadowColor: '#000', shadowOffset: { width: 2, height: 0 }, shadowOpacity: 0.08, @@ -145,8 +145,8 @@ const styles = StyleSheet.create({ borderBottomRightRadius: 24, }, sectionTitle: { - fontSize: 12, - fontWeight: '600', + fontSize: 13, + fontWeight: '500', color: '#999999', fontFamily: 'System', letterSpacing: 0.5, @@ -156,20 +156,20 @@ const styles = StyleSheet.create({ viewOption: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 8, - borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + borderRadius: 12, marginBottom: 2, }, activeViewOption: { backgroundColor: '#f0f0f0', }, viewLabel: { - fontSize: 15, + fontSize: 16, fontWeight: '400', color: '#333333', fontFamily: 'System', - marginLeft: 12, + marginLeft: 14, flex: 1, }, activeViewLabel: { @@ -182,18 +182,18 @@ const styles = StyleSheet.create({ divider: { height: StyleSheet.hairlineWidth, backgroundColor: '#e1e5e9', - marginVertical: 12, + marginVertical: 16, }, showAllButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 8, - paddingHorizontal: 8, + paddingVertical: 10, + paddingHorizontal: 12, marginBottom: 4, }, showAllText: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#666666', fontFamily: 'System', @@ -205,19 +205,19 @@ const styles = StyleSheet.create({ categoryRow: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 8, - paddingHorizontal: 8, + paddingVertical: 10, + paddingHorizontal: 12, }, colorSquare: { - width: 20, - height: 20, - borderRadius: 4, + width: 22, + height: 22, + borderRadius: 6, alignItems: 'center', justifyContent: 'center', - marginRight: 10, + marginRight: 12, }, categoryName: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#333333', fontFamily: 'System', diff --git a/src/components/Calendar20/WeekView.tsx b/src/components/Calendar20/WeekView.tsx index c941e3c..a263cd9 100644 --- a/src/components/Calendar20/WeekView.tsx +++ b/src/components/Calendar20/WeekView.tsx @@ -18,7 +18,7 @@ dayjs.extend(isoWeek); const TIME_LABEL_WIDTH = 44; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 12) / 7; +const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 16) / 7; const MIN_HOUR_HEIGHT = 30; const MAX_HOUR_HEIGHT = 120; const DEFAULT_HOUR_HEIGHT = 50; @@ -285,8 +285,8 @@ const styles = StyleSheet.create({ }, headerRow: { flexDirection: 'row', - paddingHorizontal: 6, - paddingVertical: 4, + paddingHorizontal: 8, + paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, @@ -295,7 +295,7 @@ const styles = StyleSheet.create({ }, dayName: { fontSize: 11, - fontWeight: '500', + fontWeight: '400', color: '#666666', fontFamily: 'System', }, @@ -308,13 +308,13 @@ const styles = StyleSheet.create({ borderRadius: 12, alignItems: 'center', justifyContent: 'center', - marginTop: 2, + marginTop: 3, }, todayCircle: { backgroundColor: '#000000', }, dateNum: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -325,8 +325,8 @@ const styles = StyleSheet.create({ }, allDayRow: { flexDirection: 'row', - paddingHorizontal: 6, - paddingVertical: 4, + paddingHorizontal: 8, + paddingVertical: 6, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', backgroundColor: '#fafafa', @@ -335,7 +335,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, allDayLabelText: { - fontSize: 10, + fontSize: 11, color: '#666666', fontFamily: 'System', }, @@ -347,7 +347,7 @@ const styles = StyleSheet.create({ }, gridContainer: { flexDirection: 'row', - paddingHorizontal: 6, + paddingHorizontal: 8, }, dayColumn: { position: 'relative', @@ -362,7 +362,7 @@ const styles = StyleSheet.create({ backgroundColor: '#e1e5e9', }, timeLabel: { - fontSize: 10, + fontSize: 11, color: '#999999', fontFamily: 'System', marginTop: -5, From ed45f324448760dcf658d3b0f3a24aedd19b2b83 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 14:44:48 +0100 Subject: [PATCH 23/29] refactor: migliora UI del Calendar20 con nuovo modal e view selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aggiunto Calendar20Modal per wrapper modale fullscreen - Creato ViewSelector component per selezione vista calendario - Migliorato stile di tutti i componenti Calendar20: * Aumentato padding e spacing per migliore leggibilitΓ  * Aggiornato border radius a 16px su task cards * Migliorato shadow e elevation per depth visivo * Regolato font sizes e weights per gerarchia visiva - Semplificato SearchOverlay riducendo codice ridondante - Ottimizzato MiniCalendar rimuovendo codice non necessario - Disabilitato sticky headers in AgendaView per UX piΓΉ fluida Co-Authored-By: Claude Sonnet 4.5 --- src/components/Calendar20/AgendaView.tsx | 69 +++-- src/components/Calendar20/Calendar20Modal.tsx | 32 ++ src/components/Calendar20/Calendar20View.tsx | 21 +- src/components/Calendar20/DayView.tsx | 10 +- src/components/Calendar20/EventChip.tsx | 8 +- src/components/Calendar20/FABMenu.tsx | 8 +- src/components/Calendar20/MiniCalendar.tsx | 11 - src/components/Calendar20/MonthView.tsx | 25 +- src/components/Calendar20/SearchOverlay.tsx | 60 +--- src/components/Calendar20/ThreeDayView.tsx | 16 +- src/components/Calendar20/TimeBlock.tsx | 16 +- src/components/Calendar20/TopBar.tsx | 44 +-- src/components/Calendar20/ViewSelector.tsx | 285 ++++++++++++++++++ src/components/Calendar20/WeekView.tsx | 22 +- src/locales/en.json | 3 +- 15 files changed, 459 insertions(+), 171 deletions(-) create mode 100644 src/components/Calendar20/Calendar20Modal.tsx create mode 100644 src/components/Calendar20/ViewSelector.tsx diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx index a9356b2..b52a62e 100644 --- a/src/components/Calendar20/AgendaView.tsx +++ b/src/components/Calendar20/AgendaView.tsx @@ -109,7 +109,7 @@ const AgendaView: React.FC = ({ > @@ -149,7 +149,7 @@ const AgendaView: React.FC = ({ renderItem={renderItem} renderSectionHeader={renderSectionHeader} keyExtractor={(item, index) => item.task_id || item.id || `empty-${index}`} - stickySectionHeadersEnabled={true} + stickySectionHeadersEnabled={false} style={styles.container} contentContainerStyle={styles.contentContainer} onEndReached={loadMore} @@ -170,21 +170,20 @@ const styles = StyleSheet.create({ }, contentContainer: { paddingBottom: 80, + paddingHorizontal: 16, }, sectionHeader: { - paddingHorizontal: 20, - paddingVertical: 10, - backgroundColor: '#f8f9fa', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#e1e5e9', + paddingHorizontal: 4, + paddingVertical: 14, + backgroundColor: 'transparent', }, todayHeader: { - backgroundColor: '#f0f0f0', + backgroundColor: 'transparent', }, sectionTitle: { - fontSize: 15, - fontWeight: '400', - color: '#333333', + fontSize: 17, + fontWeight: '300', + color: '#000000', fontFamily: 'System', letterSpacing: -0.3, }, @@ -195,52 +194,62 @@ const styles = StyleSheet.create({ taskRow: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 14, + paddingHorizontal: 16, + paddingVertical: 16, + marginBottom: 8, backgroundColor: '#ffffff', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#f0f0f0', + borderRadius: 16, + borderWidth: 1, + borderColor: '#e1e5e9', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.04, + shadowRadius: 8, + elevation: 1, }, checkbox: { - marginRight: 10, + marginRight: 12, }, colorDot: { width: 10, height: 10, borderRadius: 5, - marginRight: 12, + marginRight: 14, }, taskContent: { flex: 1, }, taskTitle: { - fontSize: 16, + fontSize: 17, fontWeight: '400', color: '#000000', fontFamily: 'System', + letterSpacing: -0.3, }, completedTitle: { textDecorationLine: 'line-through', color: '#999999', }, taskTime: { - fontSize: 13, + fontSize: 14, color: '#666666', fontFamily: 'System', - marginTop: 2, + marginTop: 4, }, categoryBadge: { - fontSize: 12, - fontWeight: '500', + fontSize: 13, + fontWeight: '400', fontFamily: 'System', - maxWidth: 80, + maxWidth: 90, }, emptyDay: { - paddingHorizontal: 20, - paddingVertical: 14, + paddingHorizontal: 16, + paddingVertical: 20, + marginBottom: 8, backgroundColor: '#ffffff', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#f0f0f0', + borderRadius: 16, + borderWidth: 1, + borderColor: '#f0f0f0', }, emptyText: { fontSize: 15, @@ -252,13 +261,13 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 20, + paddingVertical: 24, gap: 8, }, loadMoreText: { - fontSize: 15, + fontSize: 16, color: '#000000', - fontWeight: '500', + fontWeight: '400', fontFamily: 'System', }, }); diff --git a/src/components/Calendar20/Calendar20Modal.tsx b/src/components/Calendar20/Calendar20Modal.tsx new file mode 100644 index 0000000..4ee8507 --- /dev/null +++ b/src/components/Calendar20/Calendar20Modal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Modal, StyleSheet, View } from 'react-native'; +import Calendar20View from './Calendar20View'; + +interface Calendar20ModalProps { + visible: boolean; + onClose: () => void; +} + +const Calendar20Modal: React.FC = ({ visible, onClose }) => { + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, +}); + +export default Calendar20Modal; diff --git a/src/components/Calendar20/Calendar20View.tsx b/src/components/Calendar20/Calendar20View.tsx index 0bd4aa4..9f1d5ff 100644 --- a/src/components/Calendar20/Calendar20View.tsx +++ b/src/components/Calendar20/Calendar20View.tsx @@ -16,7 +16,7 @@ import ThreeDayView from './ThreeDayView'; import DayView from './DayView'; import AgendaView from './AgendaView'; import MiniCalendar from './MiniCalendar'; -import ViewDrawer from './ViewDrawer'; +import ViewSelector from './ViewSelector'; import SearchOverlay from './SearchOverlay'; import FABMenu from './FABMenu'; import AddTask from '../Task/AddTask'; @@ -26,14 +26,18 @@ dayjs.extend(isoWeek); const VIEW_PREF_KEY = '@calendar20_view_pref'; -const Calendar20View: React.FC = () => { +interface Calendar20ViewProps { + onClose?: () => void; +} + +const Calendar20View: React.FC = ({ onClose }) => { const [viewType, setViewType] = useState('month'); const [currentDate, setCurrentDate] = useState(dayjs()); const [rawTasks, setRawTasks] = useState([]); const [categories, setCategories] = useState([]); const [enabledCategories, setEnabledCategories] = useState>(new Set()); const [isLoading, setIsLoading] = useState(true); - const [drawerVisible, setDrawerVisible] = useState(false); + const [viewSelectorVisible, setViewSelectorVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false); const [miniCalendarVisible, setMiniCalendarVisible] = useState(false); const [addTaskVisible, setAddTaskVisible] = useState(false); @@ -217,7 +221,7 @@ const Calendar20View: React.FC = () => { const handleViewChange = useCallback(async (newView: CalendarViewType) => { setViewType(newView); - setDrawerVisible(false); + setViewSelectorVisible(false); try { await AsyncStorage.setItem(VIEW_PREF_KEY, newView); } catch {} @@ -340,10 +344,11 @@ const Calendar20View: React.FC = () => { setDrawerVisible(true)} + onMenuPress={() => setViewSelectorVisible(true)} onSearchPress={() => setSearchVisible(true)} onTodayPress={() => setCurrentDate(dayjs())} onTitlePress={() => setMiniCalendarVisible(true)} + onClose={onClose} /> {renderView()} @@ -352,8 +357,8 @@ const Calendar20View: React.FC = () => { onNewTask={() => setAddTaskVisible(true)} /> - { onViewChange={handleViewChange} onCategoryToggle={handleCategoryToggle} onShowAll={handleShowAll} - onClose={() => setDrawerVisible(false)} + onClose={() => setViewSelectorVisible(false)} /> = ({ task, onPress, isSpanning, isStar const styles = StyleSheet.create({ chip: { paddingHorizontal: 6, - paddingVertical: 2, + paddingVertical: 3, borderRadius: 6, - marginBottom: 1, - minHeight: 18, + marginBottom: 2, + minHeight: 20, justifyContent: 'center', }, spanningLeft: { @@ -54,7 +54,7 @@ const styles = StyleSheet.create({ }, chipText: { color: '#ffffff', - fontSize: 11, + fontSize: 12, fontWeight: '400', fontFamily: 'System', }, diff --git a/src/components/Calendar20/FABMenu.tsx b/src/components/Calendar20/FABMenu.tsx index ed70f2c..263f787 100644 --- a/src/components/Calendar20/FABMenu.tsx +++ b/src/components/Calendar20/FABMenu.tsx @@ -16,7 +16,7 @@ const FABMenu: React.FC = ({ onNewTask }) => { onPress={onNewTask} activeOpacity={0.8} > - + ); }; @@ -26,9 +26,9 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 24, right: 20, - width: 56, - height: 56, - borderRadius: 28, + width: 60, + height: 60, + borderRadius: 30, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center', diff --git a/src/components/Calendar20/MiniCalendar.tsx b/src/components/Calendar20/MiniCalendar.tsx index 8c7ac5c..7158cfc 100644 --- a/src/components/Calendar20/MiniCalendar.tsx +++ b/src/components/Calendar20/MiniCalendar.tsx @@ -112,17 +112,6 @@ const MiniCalendar: React.FC = ({ ))} ))} - - {/* Today button */} - { - setDisplayMonth(dayjs()); - onDateSelect(dayjs()); - }} - > - {t('calendar20.today')} - diff --git a/src/components/Calendar20/MonthView.tsx b/src/components/Calendar20/MonthView.tsx index cb1de51..aa5d017 100644 --- a/src/components/Calendar20/MonthView.tsx +++ b/src/components/Calendar20/MonthView.tsx @@ -17,7 +17,7 @@ dayjs.extend(isoWeek); const { width: SCREEN_WIDTH } = Dimensions.get('window'); const DAY_WIDTH = (SCREEN_WIDTH - 32) / 7; -const MAX_CHIPS = 3; +const MAX_CHIPS = 2; interface MonthViewProps { currentDate: dayjs.Dayjs; @@ -172,7 +172,7 @@ const styles = StyleSheet.create({ }, headerRow: { flexDirection: 'row', - paddingVertical: 10, + paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, @@ -181,8 +181,8 @@ const styles = StyleSheet.create({ alignItems: 'center', }, headerText: { - fontSize: 12, - fontWeight: '500', + fontSize: 13, + fontWeight: '400', color: '#666666', fontFamily: 'System', }, @@ -194,26 +194,27 @@ const styles = StyleSheet.create({ flex: 1, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#f0f0f0', + paddingVertical: 2, }, dayCell: { width: DAY_WIDTH, - paddingTop: 2, + paddingTop: 6, paddingHorizontal: 1, }, dateCircle: { - width: 28, - height: 28, - borderRadius: 14, + width: 32, + height: 32, + borderRadius: 16, alignItems: 'center', justifyContent: 'center', alignSelf: 'center', - marginBottom: 2, + marginBottom: 4, }, todayCircle: { backgroundColor: '#000000', }, dateText: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -232,11 +233,11 @@ const styles = StyleSheet.create({ flex: 1, }, moreText: { - fontSize: 11, + fontSize: 12, color: '#666666', fontFamily: 'System', textAlign: 'center', - marginTop: 1, + marginTop: 2, }, }); diff --git a/src/components/Calendar20/SearchOverlay.tsx b/src/components/Calendar20/SearchOverlay.tsx index a529ec7..30e4d58 100644 --- a/src/components/Calendar20/SearchOverlay.tsx +++ b/src/components/Calendar20/SearchOverlay.tsx @@ -13,6 +13,8 @@ import { import { Ionicons } from '@expo/vector-icons'; import { CalendarTask } from './types'; import { useTranslation } from 'react-i18next'; +import TaskCard from '../Task/TaskCard'; +import { Task } from '../../services/taskService'; interface SearchOverlayProps { visible: boolean; @@ -90,30 +92,17 @@ const SearchOverlay: React.FC = ({ data={results} keyExtractor={(item, i) => (item.task_id || item.id || i).toString()} renderItem={({ item }) => ( - { handleClose(); onTaskPress(item); }} - > - - - {item.title} - - {item.startDayjs.format('ddd, D MMM YYYY')} - {!item.isAllDay && ` ${item.startDayjs.format('HH:mm')}`} - - - {item.category_name && ( - - {item.category_name} - - )} - + /> )} keyboardShouldPersistTaps="handled" style={styles.resultsList} + contentContainerStyle={styles.resultsContent} /> @@ -164,40 +153,9 @@ const styles = StyleSheet.create({ resultsList: { flex: 1, }, - resultRow: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#f0f0f0', - }, - colorDot: { - width: 10, - height: 10, - borderRadius: 5, - marginRight: 14, - }, - resultContent: { - flex: 1, - }, - resultTitle: { - fontSize: 16, - fontWeight: '400', - color: '#000000', - fontFamily: 'System', - }, - resultDate: { - fontSize: 13, - color: '#666666', - fontFamily: 'System', - marginTop: 2, - }, - resultCategory: { - fontSize: 12, - fontWeight: '500', - fontFamily: 'System', - maxWidth: 80, + resultsContent: { + paddingHorizontal: 16, + paddingTop: 8, }, noResults: { alignItems: 'center', diff --git a/src/components/Calendar20/ThreeDayView.tsx b/src/components/Calendar20/ThreeDayView.tsx index 9d092a3..bd48039 100644 --- a/src/components/Calendar20/ThreeDayView.tsx +++ b/src/components/Calendar20/ThreeDayView.tsx @@ -13,8 +13,8 @@ import TimeBlock from './TimeBlock'; import EventChip from './EventChip'; import { useTranslation } from 'react-i18next'; -const HOUR_HEIGHT = 60; -const TIME_LABEL_WIDTH = 44; +const HOUR_HEIGHT = 72; +const TIME_LABEL_WIDTH = 48; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 16) / 3; @@ -236,13 +236,13 @@ const styles = StyleSheet.create({ headerRow: { flexDirection: 'row', paddingHorizontal: 8, - paddingVertical: 6, + paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, dayHeader: { alignItems: 'center' }, dayName: { - fontSize: 12, + fontSize: 13, fontWeight: '400', color: '#666666', fontFamily: 'System', @@ -254,10 +254,10 @@ const styles = StyleSheet.create({ borderRadius: 14, alignItems: 'center', justifyContent: 'center', - marginTop: 3, + marginTop: 4, }, todayCircle: { backgroundColor: '#000000' }, - dateNum: { fontSize: 17, fontWeight: '400', color: '#000000', fontFamily: 'System' }, + dateNum: { fontSize: 18, fontWeight: '400', color: '#000000', fontFamily: 'System' }, todayDateNum: { color: '#ffffff', fontWeight: '600' }, allDayRow: { flexDirection: 'row', @@ -267,7 +267,7 @@ const styles = StyleSheet.create({ borderBottomColor: '#e1e5e9', backgroundColor: '#fafafa', }, - allDayLabelText: { fontSize: 11, color: '#666666', fontFamily: 'System' }, + allDayLabelText: { fontSize: 12, color: '#666666', fontFamily: 'System' }, allDayCell: { paddingHorizontal: 1 }, scrollContainer: { flex: 1 }, gridContainer: { flexDirection: 'row', paddingHorizontal: 8 }, @@ -283,7 +283,7 @@ const styles = StyleSheet.create({ height: StyleSheet.hairlineWidth, backgroundColor: '#e1e5e9', }, - timeLabel: { fontSize: 11, color: '#999999', fontFamily: 'System', marginTop: -5 }, + timeLabel: { fontSize: 12, color: '#999999', fontFamily: 'System', marginTop: -5 }, currentTimeLine: { position: 'absolute', left: -2, diff --git a/src/components/Calendar20/TimeBlock.tsx b/src/components/Calendar20/TimeBlock.tsx index 90ae2af..ad77e5b 100644 --- a/src/components/Calendar20/TimeBlock.tsx +++ b/src/components/Calendar20/TimeBlock.tsx @@ -23,7 +23,7 @@ const TimeBlock: React.FC = ({ onToggleComplete, }) => { const startHour = task.startDayjs.hour() + task.startDayjs.minute() / 60; - const height = Math.max((task.durationMinutes / 60) * hourHeight, 20); + const height = Math.max((task.durationMinutes / 60) * hourHeight, 24); const top = startHour * hourHeight; const width = columnWidth / totalColumns - 2; const left = column * (columnWidth / totalColumns) + 1; @@ -33,7 +33,7 @@ const TimeBlock: React.FC = ({ const startTime = task.startDayjs.format('HH:mm'); const endTime = task.endDayjs.format('HH:mm'); - const showEndTime = height > 35; + const showEndTime = height > 40; return ( = ({ const styles = StyleSheet.create({ block: { position: 'absolute', - borderRadius: 8, - paddingHorizontal: 6, - paddingVertical: 3, + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 4, overflow: 'hidden', borderLeftWidth: 3, borderLeftColor: 'rgba(0,0,0,0.15)', @@ -103,7 +103,7 @@ const styles = StyleSheet.create({ marginRight: 4, }, title: { - fontSize: 12, + fontSize: 13, fontWeight: '400', color: '#ffffff', fontFamily: 'System', @@ -113,10 +113,10 @@ const styles = StyleSheet.create({ textDecorationLine: 'line-through', }, time: { - fontSize: 11, + fontSize: 12, color: 'rgba(255,255,255,0.8)', fontFamily: 'System', - marginTop: 1, + marginTop: 2, }, }); diff --git a/src/components/Calendar20/TopBar.tsx b/src/components/Calendar20/TopBar.tsx index 41620b5..b5f149d 100644 --- a/src/components/Calendar20/TopBar.tsx +++ b/src/components/Calendar20/TopBar.tsx @@ -12,6 +12,7 @@ interface TopBarProps { onSearchPress: () => void; onTodayPress: () => void; onTitlePress: () => void; + onClose?: () => void; } const TopBar: React.FC = ({ @@ -21,6 +22,7 @@ const TopBar: React.FC = ({ onSearchPress, onTodayPress, onTitlePress, + onClose, }) => { const { t } = useTranslation(); @@ -50,18 +52,24 @@ const TopBar: React.FC = ({ return ( - - - + {onClose ? ( + + + + ) : ( + + + + )} {getTitle()} - + - + {!isToday && ( @@ -77,47 +85,47 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 10 : 10, - paddingBottom: 12, - paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 14 : 14, + paddingBottom: 16, + paddingHorizontal: 20, backgroundColor: '#ffffff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, iconButton: { - padding: 4, + padding: 6, }, titleContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', - marginLeft: 12, + marginLeft: 14, }, title: { - fontSize: 20, + fontSize: 26, fontWeight: '200', color: '#000000', fontFamily: 'System', - letterSpacing: -0.8, + letterSpacing: -1, }, chevron: { - marginLeft: 4, - marginTop: 1, + marginLeft: 6, + marginTop: 2, }, rightActions: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 10, }, todayButton: { - paddingHorizontal: 16, - paddingVertical: 6, + paddingHorizontal: 18, + paddingVertical: 8, borderRadius: 20, borderWidth: 1, borderColor: '#000000', }, todayText: { - fontSize: 14, + fontSize: 15, fontWeight: '400', color: '#000000', fontFamily: 'System', diff --git a/src/components/Calendar20/ViewSelector.tsx b/src/components/Calendar20/ViewSelector.tsx new file mode 100644 index 0000000..63bbb1e --- /dev/null +++ b/src/components/Calendar20/ViewSelector.tsx @@ -0,0 +1,285 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + StyleSheet, + Pressable, + ScrollView, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CalendarViewType } from './types'; +import CategoryColorService from './categoryColors'; +import { useTranslation } from 'react-i18next'; + +interface ViewSelectorProps { + visible: boolean; + currentView: CalendarViewType; + categories: any[]; + enabledCategories: Set; + colorService: CategoryColorService; + onViewChange: (view: CalendarViewType) => void; + onCategoryToggle: (categoryName: string) => void; + onShowAll: () => void; + onClose: () => void; +} + +const VIEW_OPTIONS: { key: CalendarViewType; icon: keyof typeof Ionicons.glyphMap; labelKey: string }[] = [ + { key: 'month', icon: 'grid-outline', labelKey: 'calendar20.views.month' }, + { key: 'week', icon: 'calendar-outline', labelKey: 'calendar20.views.week' }, + { key: '3day', icon: 'albums-outline', labelKey: 'calendar20.views.threeDay' }, + { key: 'day', icon: 'today-outline', labelKey: 'calendar20.views.day' }, + { key: 'agenda', icon: 'list-outline', labelKey: 'calendar20.views.agenda' }, +]; + +const ViewSelector: React.FC = ({ + visible, + currentView, + categories, + enabledCategories, + colorService, + onViewChange, + onCategoryToggle, + onShowAll, + onClose, +}) => { + const { t } = useTranslation(); + + if (!visible) return null; + + return ( + + + e.stopPropagation()}> + + {t('calendar20.drawer.settings')} + + + + + + + {/* Views section */} + {t('calendar20.drawer.views').toUpperCase()} + + {VIEW_OPTIONS.map(opt => { + const isActive = currentView === opt.key; + return ( + onViewChange(opt.key)} + > + + + {t(opt.labelKey)} + + + ); + })} + + + {/* Categories section */} + {categories.length > 0 && ( + <> + + {t('calendar20.drawer.categories').toUpperCase()} + + + + {t('calendar20.drawer.showAll')} + + {enabledCategories.size === 0 && ( + + )} + + + + {categories.map((cat, i) => { + const name = cat.name || cat.category_name || ''; + const color = colorService.getColor(name); + const key = name.toLowerCase().trim(); + const isEnabled = enabledCategories.size === 0 || enabledCategories.has(key); + + return ( + onCategoryToggle(name)} + > + + + {name} + + {isEnabled && ( + + )} + + ); + })} + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + panel: { + backgroundColor: '#ffffff', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + maxHeight: '80%', + paddingBottom: 40, + shadowColor: '#000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.1, + shadowRadius: 16, + elevation: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e1e5e9', + }, + headerTitle: { + fontSize: 22, + fontWeight: '200', + color: '#000000', + fontFamily: 'System', + letterSpacing: -1, + }, + closeButton: { + padding: 4, + }, + scrollContent: { + paddingHorizontal: 24, + paddingTop: 20, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '500', + color: '#999999', + fontFamily: 'System', + letterSpacing: 0.8, + marginBottom: 16, + marginTop: 8, + }, + viewGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginBottom: 8, + }, + viewCard: { + width: '30%', + aspectRatio: 1.2, + backgroundColor: '#f8f9fa', + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1.5, + borderColor: '#e1e5e9', + paddingVertical: 12, + gap: 8, + }, + activeViewCard: { + backgroundColor: '#f0f7ff', + borderColor: '#007AFF', + }, + viewLabel: { + fontSize: 14, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + activeViewLabel: { + color: '#007AFF', + fontWeight: '500', + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#e1e5e9', + marginVertical: 24, + }, + showAllButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: '#f8f9fa', + borderRadius: 12, + marginBottom: 16, + }, + showAllText: { + fontSize: 15, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + activeShowAll: { + color: '#007AFF', + fontWeight: '500', + }, + categoriesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + categoryCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 12, + borderWidth: 1, + borderColor: '#e1e5e9', + minWidth: '45%', + maxWidth: '48%', + }, + disabledCategoryCard: { + backgroundColor: '#f8f9fa', + opacity: 0.6, + }, + colorDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 8, + }, + categoryName: { + fontSize: 14, + fontWeight: '400', + color: '#333333', + fontFamily: 'System', + flex: 1, + }, + disabledCategory: { + color: '#999999', + }, + categoryCheck: { + marginLeft: 4, + }, +}); + +export default React.memo(ViewSelector); diff --git a/src/components/Calendar20/WeekView.tsx b/src/components/Calendar20/WeekView.tsx index a263cd9..a98ddc8 100644 --- a/src/components/Calendar20/WeekView.tsx +++ b/src/components/Calendar20/WeekView.tsx @@ -19,9 +19,9 @@ dayjs.extend(isoWeek); const TIME_LABEL_WIDTH = 44; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const COLUMN_WIDTH = (SCREEN_WIDTH - TIME_LABEL_WIDTH - 16) / 7; -const MIN_HOUR_HEIGHT = 30; -const MAX_HOUR_HEIGHT = 120; -const DEFAULT_HOUR_HEIGHT = 50; +const MIN_HOUR_HEIGHT = 40; +const MAX_HOUR_HEIGHT = 140; +const DEFAULT_HOUR_HEIGHT = 64; interface WeekViewProps { currentDate: dayjs.Dayjs; @@ -286,7 +286,7 @@ const styles = StyleSheet.create({ headerRow: { flexDirection: 'row', paddingHorizontal: 8, - paddingVertical: 6, + paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e1e5e9', }, @@ -294,7 +294,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, dayName: { - fontSize: 11, + fontSize: 12, fontWeight: '400', color: '#666666', fontFamily: 'System', @@ -303,18 +303,18 @@ const styles = StyleSheet.create({ color: '#000000', }, dateCircle: { - width: 24, - height: 24, - borderRadius: 12, + width: 28, + height: 28, + borderRadius: 14, alignItems: 'center', justifyContent: 'center', - marginTop: 3, + marginTop: 4, }, todayCircle: { backgroundColor: '#000000', }, dateNum: { - fontSize: 15, + fontSize: 16, fontWeight: '400', color: '#000000', fontFamily: 'System', @@ -335,7 +335,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, allDayLabelText: { - fontSize: 11, + fontSize: 12, color: '#666666', fontFamily: 'System', }, diff --git a/src/locales/en.json b/src/locales/en.json index 29a6435..bea7817 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -459,7 +459,8 @@ "drawer": { "views": "Views", "categories": "Categories", - "showAll": "Show all" + "showAll": "Show all", + "settings": "Settings" } }, "statistics": { From 0bc22930501bbbdb076e5929af7bdd249557fa9f Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 14:45:45 +0100 Subject: [PATCH 24/29] add: aggiunte animazioni per il modal in calendaView --- src/components/Calendar20/ViewSelector.tsx | 67 +++++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/src/components/Calendar20/ViewSelector.tsx b/src/components/Calendar20/ViewSelector.tsx index 63bbb1e..6be73d0 100644 --- a/src/components/Calendar20/ViewSelector.tsx +++ b/src/components/Calendar20/ViewSelector.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { View, Text, @@ -7,6 +7,7 @@ import { StyleSheet, Pressable, ScrollView, + Animated, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { CalendarViewType } from './types'; @@ -45,16 +46,65 @@ const ViewSelector: React.FC = ({ onClose, }) => { const { t } = useTranslation(); + const [modalVisible, setModalVisible] = useState(false); + const slideAnim = useRef(new Animated.Value(300)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; - if (!visible) return null; + useEffect(() => { + if (visible) { + setModalVisible(true); + Animated.parallel([ + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + tension: 65, + friction: 10, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + ]).start(); + } else if (modalVisible) { + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: 300, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + ]).start(() => { + setModalVisible(false); + }); + } + }, [visible, modalVisible, slideAnim, fadeAnim]); + + const handleClose = () => { + onClose(); + }; + + if (!modalVisible) return null; return ( - - - e.stopPropagation()}> + + + + {t('calendar20.drawer.settings')} - + @@ -126,8 +176,8 @@ const ViewSelector: React.FC = ({ )} - - + + ); }; @@ -137,6 +187,7 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end', + alignItems: 'stretch', }, panel: { backgroundColor: '#ffffff', From 4433833e17643e9370489046131f2a6388e72632 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 6 Feb 2026 15:10:30 +0100 Subject: [PATCH 25/29] add: migliorato il calendar20 e aggiutna la funzionta di creare un task cliccando sul calendario, poi messo il calendario nellos screen con Calendar.tsx facendo scegliere all`utente che visualizzaione del calendario vuole avere --- src/components/Calendar20/AgendaView.tsx | 30 ++++++-- src/components/Calendar20/Calendar20View.tsx | 29 +++++--- src/components/Calendar20/DayView.tsx | 29 ++++++-- src/components/Calendar20/ThreeDayView.tsx | 24 ++++++- src/components/Calendar20/TopBar.tsx | 4 +- src/components/Calendar20/WeekView.tsx | 23 ++++++- src/navigation/index.tsx | 10 --- src/navigation/screens/Calendar.tsx | 72 ++++++++++++++++++-- 8 files changed, 184 insertions(+), 37 deletions(-) diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx index b52a62e..d5c69b8 100644 --- a/src/components/Calendar20/AgendaView.tsx +++ b/src/components/Calendar20/AgendaView.tsx @@ -34,6 +34,7 @@ interface AgendaSection { const AgendaView: React.FC = ({ currentDate, tasks, + onDatePress, onTaskPress, onToggleComplete, }) => { @@ -82,12 +83,17 @@ const AgendaView: React.FC = ({ setDaysToShow(prev => prev + LOAD_MORE_DAYS); }, []); - const renderItem = useCallback(({ item }: { item: CalendarTask }) => { + const renderItem = useCallback(({ item, section }: { item: CalendarTask; section: AgendaSection }) => { if ((item as any)._empty) { return ( - + onDatePress(section.date.hour(12).minute(0).second(0))} + > {t('calendar20.noEvents')} - + + ); } @@ -140,8 +146,15 @@ const AgendaView: React.FC = ({ {section.title} + onDatePress(section.date.hour(12).minute(0).second(0))} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + - ), []); + ), [onDatePress]); return ( = ({ onClose }) => { const [searchVisible, setSearchVisible] = useState(false); const [miniCalendarVisible, setMiniCalendarVisible] = useState(false); const [addTaskVisible, setAddTaskVisible] = useState(false); + const [selectedDateForTask, setSelectedDateForTask] = useState(null); const cacheService = useRef(TaskCacheService.getInstance()).current; const appInitializer = useRef(AppInitializer.getInstance()).current; @@ -228,11 +229,10 @@ const Calendar20View: React.FC = ({ onClose }) => { }, []); const handleDatePress = useCallback((date: dayjs.Dayjs) => { - setCurrentDate(date); - if (viewType === 'month') { - setViewType('day'); - } - }, [viewType]); + // Apri il modal per creare un task con la data selezionata + setSelectedDateForTask(date); + setAddTaskVisible(true); + }, []); const handleTaskPress = useCallback((task: CalendarTask) => { // Navigate to day view for the task @@ -266,10 +266,12 @@ const Calendar20View: React.FC = ({ onClose }) => { const { addTask } = await import('../../services/taskService'); const priorityString = priority === 1 ? 'Bassa' : priority === 2 ? 'Media' : 'Alta'; const category = categoryNameParam || 'Calendario'; + // Usa la data selezionata se disponibile, altrimenti usa la data corrente + const taskDate = selectedDateForTask || currentDate; const newTask: Task = { title: title.trim(), description: description || '', - start_time: currentDate.toISOString(), + start_time: taskDate.toISOString(), end_time: new Date(dueDate).toISOString(), priority: priorityString, status: 'In sospeso', @@ -281,7 +283,8 @@ const Calendar20View: React.FC = ({ onClose }) => { console.error('[CALENDAR20] Error adding task:', error); } setAddTaskVisible(false); - }, [currentDate]); + setSelectedDateForTask(null); + }, [currentDate, selectedDateForTask]); const handleCategoryToggle = useCallback((categoryName: string) => { setEnabledCategories(prev => { @@ -354,7 +357,10 @@ const Calendar20View: React.FC = ({ onClose }) => { {renderView()} setAddTaskVisible(true)} + onNewTask={() => { + setSelectedDateForTask(null); + setAddTaskVisible(true); + }} /> = ({ onClose }) => { setAddTaskVisible(false)} + onClose={() => { + setAddTaskVisible(false); + setSelectedDateForTask(null); + }} onSave={handleSaveTask} allowCategorySelection={true} categoryName="Calendario" - initialDate={currentDate.format('YYYY-MM-DD')} + initialDate={(selectedDateForTask || currentDate).format('YYYY-MM-DD')} /> ); diff --git a/src/components/Calendar20/DayView.tsx b/src/components/Calendar20/DayView.tsx index 534a66f..c335559 100644 --- a/src/components/Calendar20/DayView.tsx +++ b/src/components/Calendar20/DayView.tsx @@ -6,6 +6,7 @@ import { StyleSheet, Dimensions, PanResponder, + TouchableOpacity, } from 'react-native'; import dayjs from 'dayjs'; import { CalendarTask, OverlapColumn } from './types'; @@ -86,6 +87,7 @@ function computeOverlapColumns(tasks: CalendarTask[]): OverlapColumn[] { const DayView: React.FC = ({ currentDate, tasks, + onDatePress, onTaskPress, onToggleComplete, onSwipeLeft, @@ -164,7 +166,7 @@ const DayView: React.FC = ({ {/* Time labels */} {hours.map(hour => ( - + {hour.toString().padStart(2, '0')}:00 @@ -174,9 +176,22 @@ const DayView: React.FC = ({ {/* Events column */} + {/* Clickable time slots (every 30 minutes) */} + {hours.map(hour => [0, 30].map(minute => { + const slotTime = currentDate.hour(hour).minute(minute).second(0); + return ( + onDatePress(slotTime)} + /> + ); + }))} + {/* Hour grid lines */} {hours.map(hour => ( - + ))} {/* Time blocks */} @@ -195,7 +210,7 @@ const DayView: React.FC = ({ {/* Current time indicator */} {isToday && currentTimeTop >= 0 && ( - + @@ -243,9 +258,15 @@ const styles = StyleSheet.create({ timeColumn: { width: TIME_LABEL_WIDTH, }, - timeSlot: { + timeLabelSlot: { justifyContent: 'flex-start', }, + clickableSlot: { + position: 'absolute', + left: 0, + right: 0, + backgroundColor: 'transparent', + }, timeLabel: { fontSize: 12, color: '#999999', diff --git a/src/components/Calendar20/ThreeDayView.tsx b/src/components/Calendar20/ThreeDayView.tsx index bd48039..3055204 100644 --- a/src/components/Calendar20/ThreeDayView.tsx +++ b/src/components/Calendar20/ThreeDayView.tsx @@ -6,6 +6,7 @@ import { StyleSheet, Dimensions, PanResponder, + TouchableOpacity, } from 'react-native'; import dayjs from 'dayjs'; import { CalendarTask, OverlapColumn } from './types'; @@ -199,8 +200,21 @@ const ThreeDayView: React.FC = ({ key={dayIndex} style={[styles.dayColumn, { width: COLUMN_WIDTH, height: 24 * HOUR_HEIGHT }]} > + {/* Clickable time slots (every 30 minutes) */} + {hours.map(hour => [0, 30].map(minute => { + const slotTime = day.hour(hour).minute(minute).second(0); + return ( + onDatePress(slotTime)} + /> + ); + }))} + {hours.map(hour => ( - + ))} {timedTasksByDay[dayIndex].map(({ task, column, totalColumns }) => ( @@ -217,7 +231,7 @@ const ThreeDayView: React.FC = ({ ))} {isToday && currentTimeTop >= 0 && ( - + @@ -276,6 +290,12 @@ const styles = StyleSheet.create({ borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: '#f0f0f0', }, + clickableSlot: { + position: 'absolute', + left: 0, + right: 0, + backgroundColor: 'transparent', + }, hourLine: { position: 'absolute', left: 0, diff --git a/src/components/Calendar20/TopBar.tsx b/src/components/Calendar20/TopBar.tsx index b5f149d..e909453 100644 --- a/src/components/Calendar20/TopBar.tsx +++ b/src/components/Calendar20/TopBar.tsx @@ -85,8 +85,8 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 14 : 14, - paddingBottom: 16, + paddingTop: 8, + paddingBottom: 12, paddingHorizontal: 20, backgroundColor: '#ffffff', borderBottomWidth: StyleSheet.hairlineWidth, diff --git a/src/components/Calendar20/WeekView.tsx b/src/components/Calendar20/WeekView.tsx index a98ddc8..614c1bd 100644 --- a/src/components/Calendar20/WeekView.tsx +++ b/src/components/Calendar20/WeekView.tsx @@ -6,6 +6,7 @@ import { StyleSheet, Dimensions, PanResponder, + TouchableOpacity, } from 'react-native'; import dayjs from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; @@ -241,11 +242,25 @@ const WeekView: React.FC = ({ { width: COLUMN_WIDTH, height: 24 * hourHeight }, ]} > + {/* Clickable time slots (every 30 minutes) */} + {hours.map(hour => [0, 30].map(minute => { + const slotTime = day.hour(hour).minute(minute).second(0); + return ( + onDatePress(slotTime)} + /> + ); + }))} + {/* Hour lines */} {hours.map(hour => ( ))} @@ -265,7 +280,7 @@ const WeekView: React.FC = ({ {/* Current time */} {isToday && currentTimeTop >= 0 && ( - + @@ -354,6 +369,12 @@ const styles = StyleSheet.create({ borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: '#f0f0f0', }, + clickableSlot: { + position: 'absolute', + left: 0, + right: 0, + backgroundColor: 'transparent', + }, hourLine: { position: 'absolute', left: 0, diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index c8e55a7..52e526a 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -28,7 +28,6 @@ import LanguageScreen from "./screens/Language"; import VoiceSettingsScreen from "./screens/VoiceSettings"; import GoogleCalendarScreen from "./screens/GoogleCalendar"; import CalendarScreen from "./screens/Calendar"; -import Calendar20Screen from "./screens/Calendar20"; import NotificationDebugScreen from "./screens/NotificationDebug"; import BugReportScreen from "./screens/BugReport"; //import StatisticsScreen from "./screens/Statistics"; @@ -78,7 +77,6 @@ export type TabParamList = { Categories: undefined; Notes: undefined; Calendar: undefined; - Calendar20: undefined; Statistics: undefined; }; @@ -111,9 +109,6 @@ function HomeTabs() { case "Calendar": iconName = focused ? "calendar" : "calendar-outline"; break; - case "Calendar20": - iconName = focused ? "today" : "today-outline"; - break; case "Statistics": iconName = focused ? "stats-chart" : "stats-chart-outline"; break; @@ -149,11 +144,6 @@ function HomeTabs() { component={CalendarScreen} options={{ title: t('navigation.tabs.calendar') }} /> - {/* ('minimal'); + const [isLoading, setIsLoading] = useState(true); + + // Carica la preferenza salvata all'avvio + useEffect(() => { + loadViewMode(); + }, []); + + const loadViewMode = async () => { + try { + const savedMode = await AsyncStorage.getItem(CALENDAR_VIEW_MODE_KEY); + if (savedMode === 'advanced' || savedMode === 'minimal') { + setViewMode(savedMode); + } + } catch (error) { + console.error('Error loading calendar view mode:', error); + } finally { + setIsLoading(false); + } + }; + + const toggleViewMode = async () => { + const newMode = viewMode === 'minimal' ? 'advanced' : 'minimal'; + setViewMode(newMode); + try { + await AsyncStorage.setItem(CALENDAR_VIEW_MODE_KEY, newMode); + } catch (error) { + console.error('Error saving calendar view mode:', error); + } + }; + + if (isLoading) { + return ( + + + + ); + } + return ( - {/* Header con titolo principale - stesso stile di Home20 e Categories */} + {/* Header con titolo principale e toggle button */} {t('calendar.title')} + + + - + {viewMode === 'minimal' ? : } ); @@ -38,6 +94,7 @@ const styles = StyleSheet.create({ paddingBottom: 0, flexDirection: "row", alignItems: "flex-start", + justifyContent: "space-between", }, mainTitle: { paddingTop: 10, @@ -48,6 +105,13 @@ const styles = StyleSheet.create({ fontFamily: "System", letterSpacing: -1.5, marginBottom: 0, + flex: 1, + }, + toggleButton: { + paddingTop: 15, + paddingLeft: 15, + paddingRight: 5, + paddingBottom: 10, }, content: { flex: 1, From a4caae9e133a0f1b26c8d57f1b674381748d9ab0 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Sat, 7 Feb 2026 14:51:03 +0100 Subject: [PATCH 26/29] feat: integrate calendar functionality in VoiceChatModal for task management --- src/components/BotChat/VoiceChatModal.tsx | 276 +++++++++++++++++++++- 1 file changed, 274 insertions(+), 2 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 4e1fed4..ca08b76 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState, useCallback } from "react"; import { View, Text, @@ -9,10 +9,16 @@ import { Dimensions, StatusBar, Alert, - Platform + Platform, + ScrollView } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useVoiceChat } from '../../hooks/useVoiceChat'; +import dayjs from 'dayjs'; +import CalendarGrid from '../Calendar/CalendarGrid'; +import { Task as TaskType, getAllTasks } from '../../services/taskService'; +import { TaskCacheService } from '../../services/TaskCacheService'; +import eventEmitter, { EVENTS } from '../../utils/eventEmitter'; export interface VoiceChatModalProps { @@ -47,6 +53,74 @@ const VoiceChatModal: React.FC = ({ unmute, } = useVoiceChat(); + // Calendar state + const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); + const [calendarTasks, setCalendarTasks] = useState([]); + const cacheService = useRef(TaskCacheService.getInstance()).current; + + // Carica task per il calendario + const fetchCalendarTasks = useCallback(async () => { + try { + const cachedTasks = await cacheService.getCachedTasks(); + if (cachedTasks.length > 0) { + const incomplete = cachedTasks.filter(t => { + const s = t.status?.toLowerCase() || ''; + return s !== 'completato' && s !== 'completed' && s !== 'archiviato' && s !== 'archived'; + }); + setCalendarTasks(incomplete); + return; + } + const tasksData = await getAllTasks(true); + if (Array.isArray(tasksData)) { + const incomplete = tasksData.filter(t => { + const s = t.status?.toLowerCase() || ''; + return s !== 'completato' && s !== 'completed' && s !== 'archiviato' && s !== 'archived'; + }); + setCalendarTasks(incomplete); + } + } catch (error) { + console.error('[VoiceChatModal] Errore caricamento task calendario:', error); + } + }, [cacheService]); + + // Carica task quando il modal si apre + useEffect(() => { + if (visible) { + fetchCalendarTasks(); + } + }, [visible, fetchCalendarTasks]); + + // Ascolta eventi task per aggiornare il calendario + useEffect(() => { + const refresh = () => fetchCalendarTasks(); + eventEmitter.on(EVENTS.TASK_ADDED, refresh); + eventEmitter.on(EVENTS.TASK_UPDATED, refresh); + eventEmitter.on(EVENTS.TASK_DELETED, refresh); + return () => { + eventEmitter.off(EVENTS.TASK_ADDED, refresh); + eventEmitter.off(EVENTS.TASK_UPDATED, refresh); + eventEmitter.off(EVENTS.TASK_DELETED, refresh); + }; + }, [fetchCalendarTasks]); + + const goToPreviousMonth = () => { + setSelectedDate(prev => dayjs(prev).subtract(1, 'month').format('YYYY-MM-DD')); + }; + + const goToNextMonth = () => { + setSelectedDate(prev => dayjs(prev).add(1, 'month').format('YYYY-MM-DD')); + }; + + const selectDate = (date: string | null) => { + if (date) setSelectedDate(date); + }; + + // Task per la data selezionata + const tasksForSelectedDate = calendarTasks.filter(task => { + if (!task.end_time) return false; + return dayjs(task.end_time).format('YYYY-MM-DD') === selectedDate; + }); + // Animazioni const slideIn = useRef(new Animated.Value(height)).current; const fadeIn = useRef(new Animated.Value(0)).current; @@ -244,6 +318,82 @@ const VoiceChatModal: React.FC = ({ + {/* Calendar + Task List Section */} + + {/* Calendario in alto */} + + + + + {/* Header data selezionata */} + + + {dayjs(selectedDate).format('DD MMMM YYYY')} + + + {tasksForSelectedDate.length} {tasksForSelectedDate.length === 1 ? 'impegno' : 'impegni'} + + + + {/* Lista task scrollabile */} + + {tasksForSelectedDate.length > 0 ? ( + tasksForSelectedDate.map(task => ( + + + + + {task.title} + + {task.description ? ( + + {task.description} + + ) : null} + + {task.priority ? ( + + + {task.priority} + + + ) : null} + {task.category_name ? ( + {task.category_name} + ) : null} + + + + )) + ) : ( + + + Nessun impegno per questa data + + )} + + + {/* Bottom Control Bar */} {/* Microphone Button - Primary */} @@ -354,6 +504,128 @@ const styles = StyleSheet.create({ color: "#FF3B30", fontFamily: "System", }, + // Calendar + Task List Section + calendarSection: { + flex: 1, + paddingHorizontal: 20, + }, + calendarWrapper: { + paddingTop: 12, + }, + selectedDateHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingTop: 16, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: "#E1E5E9", + }, + selectedDateTitle: { + fontSize: 16, + fontWeight: "300", + color: "#000000", + fontFamily: "System", + letterSpacing: -0.5, + }, + taskCountLabel: { + fontSize: 13, + fontWeight: "400", + color: "#999999", + fontFamily: "System", + }, + taskListScroll: { + flex: 1, + }, + taskListContent: { + paddingTop: 8, + paddingBottom: 16, + }, + taskItem: { + flexDirection: "row", + alignItems: "flex-start", + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#F0F0F0", + }, + taskItemDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: "#000000", + marginTop: 6, + marginRight: 12, + }, + taskItemBody: { + flex: 1, + }, + taskItemTitle: { + fontSize: 15, + fontWeight: "400", + color: "#000000", + fontFamily: "System", + }, + taskItemDesc: { + fontSize: 13, + fontWeight: "300", + color: "#888888", + fontFamily: "System", + marginTop: 3, + lineHeight: 18, + }, + taskItemMeta: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginTop: 6, + }, + taskPriorityBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 8, + backgroundColor: "#F0F0F0", + }, + priorityHigh: { + backgroundColor: "rgba(0, 0, 0, 0.08)", + }, + priorityMedium: { + backgroundColor: "rgba(51, 51, 51, 0.06)", + }, + priorityLow: { + backgroundColor: "rgba(102, 102, 102, 0.04)", + }, + taskPriorityText: { + fontSize: 11, + fontWeight: "500", + fontFamily: "System", + }, + priorityHighText: { + color: "#000000", + }, + priorityMediumText: { + color: "#333333", + }, + priorityLowText: { + color: "#888888", + }, + taskCategoryText: { + fontSize: 11, + fontWeight: "400", + color: "#999999", + fontFamily: "System", + }, + emptyTaskList: { + alignItems: "center", + justifyContent: "center", + paddingTop: 40, + }, + emptyTaskText: { + fontSize: 14, + fontWeight: "300", + color: "#999999", + fontFamily: "System", + marginTop: 10, + }, // Control Bar controlBar: { flexDirection: "row", From e789e596ede77ad0bd50b65d8511ffc8e602ead1 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Sat, 7 Feb 2026 15:02:45 +0100 Subject: [PATCH 27/29] refactor: usa componente Task nel VoiceChatModal per consistenza con CalendarView Sostituito il rendering custom dei task con il componente Task riutilizzabile, aggiungendo gli handler per completamento, modifica ed eliminazione. Co-Authored-By: Claude Opus 4.6 --- src/components/BotChat/VoiceChatModal.tsx | 169 ++++++++-------------- 1 file changed, 58 insertions(+), 111 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index ca08b76..3a510b6 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -16,9 +16,10 @@ import { Ionicons } from "@expo/vector-icons"; import { useVoiceChat } from '../../hooks/useVoiceChat'; import dayjs from 'dayjs'; import CalendarGrid from '../Calendar/CalendarGrid'; -import { Task as TaskType, getAllTasks } from '../../services/taskService'; +import { Task as TaskType, getAllTasks, completeTask, disCompleteTask, updateTask, deleteTask } from '../../services/taskService'; import { TaskCacheService } from '../../services/TaskCacheService'; import eventEmitter, { EVENTS } from '../../utils/eventEmitter'; +import Task from '../Task/Task'; export interface VoiceChatModalProps { @@ -115,12 +116,57 @@ const VoiceChatModal: React.FC = ({ if (date) setSelectedDate(date); }; - // Task per la data selezionata + // Task per la data selezionata (normalizzati con id/task_id) const tasksForSelectedDate = calendarTasks.filter(task => { if (!task.end_time) return false; return dayjs(task.end_time).format('YYYY-MM-DD') === selectedDate; + }).map(task => { + if (!task.id && task.task_id) return { ...task, id: task.task_id }; + if (task.id && !task.task_id) return { ...task, task_id: task.id }; + return task; }); + // Task handlers + const handleTaskComplete = async (taskId: number | string) => { + try { + await completeTask(taskId); + fetchCalendarTasks(); + } catch (error) { + console.error('[VoiceChatModal] Errore completamento task:', error); + Alert.alert('Errore', 'Impossibile completare il task.'); + } + }; + + const handleTaskUncomplete = async (taskId: number | string) => { + try { + await disCompleteTask(taskId); + fetchCalendarTasks(); + } catch (error) { + console.error('[VoiceChatModal] Errore annullamento completamento:', error); + Alert.alert('Errore', 'Impossibile riaprire il task.'); + } + }; + + const handleTaskEdit = async (taskId: number | string, updatedTask: TaskType) => { + try { + await updateTask(taskId, updatedTask); + fetchCalendarTasks(); + } catch (error) { + console.error('[VoiceChatModal] Errore modifica task:', error); + Alert.alert('Errore', 'Impossibile modificare il task.'); + } + }; + + const handleTaskDelete = async (taskId: number | string) => { + try { + await deleteTask(taskId); + fetchCalendarTasks(); + } catch (error) { + console.error('[VoiceChatModal] Errore eliminazione task:', error); + Alert.alert('Errore', 'Impossibile eliminare il task.'); + } + }; + // Animazioni const slideIn = useRef(new Animated.Value(height)).current; const fadeIn = useRef(new Animated.Value(0)).current; @@ -349,41 +395,14 @@ const VoiceChatModal: React.FC = ({ > {tasksForSelectedDate.length > 0 ? ( tasksForSelectedDate.map(task => ( - - - - - {task.title} - - {task.description ? ( - - {task.description} - - ) : null} - - {task.priority ? ( - - - {task.priority} - - - ) : null} - {task.category_name ? ( - {task.category_name} - ) : null} - - - + )) ) : ( @@ -538,81 +557,9 @@ const styles = StyleSheet.create({ flex: 1, }, taskListContent: { - paddingTop: 8, + paddingTop: 4, paddingBottom: 16, - }, - taskItem: { - flexDirection: "row", - alignItems: "flex-start", - paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: "#F0F0F0", - }, - taskItemDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: "#000000", - marginTop: 6, - marginRight: 12, - }, - taskItemBody: { - flex: 1, - }, - taskItemTitle: { - fontSize: 15, - fontWeight: "400", - color: "#000000", - fontFamily: "System", - }, - taskItemDesc: { - fontSize: 13, - fontWeight: "300", - color: "#888888", - fontFamily: "System", - marginTop: 3, - lineHeight: 18, - }, - taskItemMeta: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginTop: 6, - }, - taskPriorityBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 8, - backgroundColor: "#F0F0F0", - }, - priorityHigh: { - backgroundColor: "rgba(0, 0, 0, 0.08)", - }, - priorityMedium: { - backgroundColor: "rgba(51, 51, 51, 0.06)", - }, - priorityLow: { - backgroundColor: "rgba(102, 102, 102, 0.04)", - }, - taskPriorityText: { - fontSize: 11, - fontWeight: "500", - fontFamily: "System", - }, - priorityHighText: { - color: "#000000", - }, - priorityMediumText: { - color: "#333333", - }, - priorityLowText: { - color: "#888888", - }, - taskCategoryText: { - fontSize: 11, - fontWeight: "400", - color: "#999999", - fontFamily: "System", + paddingHorizontal: 4, }, emptyTaskList: { alignItems: "center", From d3bae4bc953775dc692d32d37b7d8208017b244d Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Sat, 7 Feb 2026 15:11:31 +0100 Subject: [PATCH 28/29] feat: add animated loading dots and smooth state transitions in VoiceChatModal --- src/components/BotChat/VoiceChatModal.tsx | 108 +++++++++++++++++++--- 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 3a510b6..6b1a557 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -172,6 +172,13 @@ const VoiceChatModal: React.FC = ({ const fadeIn = useRef(new Animated.Value(0)).current; const liveDotOpacity = useRef(new Animated.Value(1)).current; + // Animated loading dots for smooth state transitions + const dot1Opacity = useRef(new Animated.Value(0.3)).current; + const dot2Opacity = useRef(new Animated.Value(0.3)).current; + const dot3Opacity = useRef(new Animated.Value(0.3)).current; + const stateTextOpacity = useRef(new Animated.Value(1)).current; + const prevStateRef = useRef(state); + // Notifica trascrizioni assistant al parent useEffect(() => { if (onVoiceResponse && transcripts.length > 0) { @@ -218,6 +225,48 @@ const VoiceChatModal: React.FC = ({ } }, [visible, disconnect]); + // Loading dots sequential pulse animation + const isLoadingState = state === 'connecting' || state === 'authenticating' || state === 'setting_up' || state === 'processing'; + useEffect(() => { + if (isLoadingState) { + const animateDots = Animated.loop( + Animated.stagger(200, [ + Animated.sequence([ + Animated.timing(dot1Opacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.timing(dot1Opacity, { toValue: 0.3, duration: 400, useNativeDriver: true }), + ]), + Animated.sequence([ + Animated.timing(dot2Opacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.timing(dot2Opacity, { toValue: 0.3, duration: 400, useNativeDriver: true }), + ]), + Animated.sequence([ + Animated.timing(dot3Opacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.timing(dot3Opacity, { toValue: 0.3, duration: 400, useNativeDriver: true }), + ]), + ]) + ); + animateDots.start(); + return () => animateDots.stop(); + } else { + dot1Opacity.setValue(0.3); + dot2Opacity.setValue(0.3); + dot3Opacity.setValue(0.3); + } + }, [isLoadingState, dot1Opacity, dot2Opacity, dot3Opacity]); + + // Smooth cross-fade when state changes + useEffect(() => { + if (prevStateRef.current !== state) { + prevStateRef.current = state; + stateTextOpacity.setValue(0); + Animated.timing(stateTextOpacity, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }).start(); + } + }, [state, stateTextOpacity]); + // Live dot pulse animation useEffect(() => { if (isConnected) { @@ -280,30 +329,46 @@ const VoiceChatModal: React.FC = ({ }; - // Render dello stato + // Render loading dots + const renderLoadingDots = () => ( + + + + + + ); + + // Render dello stato con transizioni fluide const renderStateIndicator = () => { + // Stati di caricamento: mostra solo i dots animati + if (isLoadingState) { + return renderLoadingDots(); + } + + // Stati interattivi: mostra testo con fade-in + let label: string | null = null; switch (state) { - case 'connecting': - case 'authenticating': - return Connessione in corso...; - case 'setting_up': - return Preparazione assistente...; case 'error': - return Qualcosa Γ¨ andato storto; + label = 'Qualcosa Γ¨ andato storto'; + break; case 'recording': - return Ti ascolto...; - case 'processing': - if (activeTools.some(t => t.status === 'running')) { - return Sto eseguendo azioni...; - } - return Sto pensando...; + label = 'Ti ascolto...'; + break; case 'speaking': - return Rispondo...; + label = 'Rispondo...'; + break; case 'ready': - return Parla quando vuoi; + label = 'Parla quando vuoi'; + break; default: return null; } + + return ( + + {label} + + ); }; @@ -508,6 +573,19 @@ const styles = StyleSheet.create({ color: "#666666", fontFamily: "System", }, + loadingDots: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + height: 18, + }, + loadingDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: "#999999", + }, mutedBadge: { flexDirection: "row", alignItems: "center", From b8940babbd4d3f264b4e3d68fdcfb13fee7b60ce Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 9 Feb 2026 12:29:09 +0100 Subject: [PATCH 29/29] fix: resolve recursive loop in TaskCacheService causing app freeze after login - Add isSaving flag to prevent concurrent recursive calls - Create _saveTasksDirect() method for direct cache writes without comparison - Create _getCachedTasksRaw() method to read cache without triggering deduplication - getCachedTasks() now uses _saveTasksDirect() for duplicate cleanup - saveTasks() now uses _getCachedTasksRaw() for comparison - Add finally block to always reset the saving flag This fixes the infinite loop that occurred when: 1. saveTasks() called getCachedTasks() to compare tasks 2. getCachedTasks() found duplicates and called saveTasks() to clean cache 3. This created an infinite recursive loop causing the app to freeze --- src/services/TaskCacheService.ts | 56 +++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/services/TaskCacheService.ts b/src/services/TaskCacheService.ts index 3317e12..a1091ce 100644 --- a/src/services/TaskCacheService.ts +++ b/src/services/TaskCacheService.ts @@ -36,6 +36,7 @@ export interface OfflineChange { class TaskCacheService { private static instance: TaskCacheService; private currentCacheVersion = 1; + private isSaving = false; // Flag per prevenire loop ricorsivi static getInstance(): TaskCacheService { if (!TaskCacheService.instance) { @@ -44,6 +45,42 @@ class TaskCacheService { return TaskCacheService.instance; } + // Metodo interno per salvare direttamente senza confronto (evita loop ricorsivi) + private async _saveTasksDirect(tasks: Task[], categories: Category[]): Promise { + const cache: TasksCache = { + tasks, + categories, + lastSync: Date.now(), + version: this.currentCacheVersion + }; + + await AsyncStorage.setItem(CACHE_KEYS.TASKS_CACHE, JSON.stringify(cache)); + await AsyncStorage.setItem(CACHE_KEYS.LAST_SYNC_TIMESTAMP, cache.lastSync.toString()); + } + + // Metodo interno per leggere i task dalla cache senza deduplicazione ricorsiva + private async _getCachedTasksRaw(): Promise<{ tasks: Task[], categories: Category[] }> { + try { + const cachedData = await AsyncStorage.getItem(CACHE_KEYS.TASKS_CACHE); + + if (!cachedData) { + return { tasks: [], categories: [] }; + } + + const cache: TasksCache = JSON.parse(cachedData); + + // Verifica la versione della cache + if (cache.version !== this.currentCacheVersion) { + return { tasks: [], categories: [] }; + } + + return { tasks: cache.tasks || [], categories: cache.categories || [] }; + } catch (error) { + console.error('[CACHE] Errore nel caricamento raw dalla cache:', error); + return { tasks: [], categories: [] }; + } + } + // Carica i task dalla cache AsyncStorage async getCachedTasks(): Promise { try { @@ -84,9 +121,9 @@ class TaskCacheService { if (deduplicatedTasks.length < cache.tasks.length) { console.log(`[CACHE] 🧹 Rimossi ${cache.tasks.length - deduplicatedTasks.length} duplicati dalla cache`); - // Salva immediatamente la cache pulita - const categories = await this.getCachedCategories(); - await this.saveTasks(deduplicatedTasks, categories); + // Salva immediatamente la cache pulita usando il metodo diretto (evita loop ricorsivo) + const categories = cache.categories || []; + await this._saveTasksDirect(deduplicatedTasks, categories); } // Log dettagliato di ogni task in cache per debug @@ -120,11 +157,18 @@ class TaskCacheService { // Salva i task nella cache async saveTasks(tasks: Task[], categories: Category[] = []): Promise { + // Previeni loop ricorsivi + if (this.isSaving) { + console.log('[CACHE] ⚠️ saveTasks giΓ  in esecuzione, skip per prevenire loop'); + return; + } + try { + this.isSaving = true; console.log(`[CACHE] Salvando ${tasks.length} task in cache...`); - // Carica i task attuali dalla cache per confronto - const currentTasks = await this.getCachedTasks(); + // Carica i task attuali dalla cache per confronto usando il metodo raw (evita loop) + const { tasks: currentTasks } = await this._getCachedTasksRaw(); // Identifica task rimossi (presenti in cache ma non nei nuovi dati) const newTaskIds = new Set(tasks.map(task => task.task_id || task.id)); @@ -167,6 +211,8 @@ class TaskCacheService { } } catch (error) { console.error('[CACHE] Errore nel salvataggio in cache:', error); + } finally { + this.isSaving = false; } }