diff --git a/CHATBOT_TEXT_ENDPOINT_GUIDE.md b/CHATBOT_TEXT_ENDPOINT_GUIDE.md new file mode 100644 index 0000000..08f6a89 --- /dev/null +++ b/CHATBOT_TEXT_ENDPOINT_GUIDE.md @@ -0,0 +1,750 @@ +# MyTaskly Chatbot Text Endpoint - Guida Completa + +## Panoramica + +L'endpoint `/chat/text` è il cuore dell'intelligenza artificiale di MyTaskly. Permette agli utenti di interagire con un assistente AI che può creare, modificare e visualizzare task, categorie e note attraverso il protocollo MCP (Model Context Protocol). + +**Endpoint**: `POST /chat/text` + +**Autenticazione**: Richiesta (JWT token via header `Authorization: Bearer `) + +**Tipo di risposta**: Server-Sent Events (SSE) streaming + +--- + +## Architettura + +### Flusso di Elaborazione + +``` +User Input → OpenAI Agent → MCP Tools → Database → Formatted Response → SSE Stream → React Native App +``` + +### Componenti Chiave + +1. **OpenAI Agents SDK**: Gestisce la logica conversazionale e le decisioni +2. **MCP Server Esterno**: Fornisce strumenti per manipolare task, categorie, note +3. **Sistema di Visualizzazione**: Determina quali output mostrare all'utente +4. **Streaming SSE**: Invia risposte in tempo reale chunk per chunk + +--- + +## Formato della Richiesta + +### Schema JSON + +```json +{ + "quest": "string (richiesto)", + "model": "string (opzionale: 'base' o 'advanced')", + "previous_messages": [ + { + "role": "user | assistant", + "content": "string" + } + ] +} +``` + +### Parametri + +- **quest**: La domanda/richiesta dell'utente (max consigliato: 500 caratteri) +- **model**: + - `"base"` → usa GPT-3.5-Turbo (più veloce, meno costoso) + - `"advanced"` → usa GPT-4 (più intelligente, più lento) + - Default: `"advanced"` +- **previous_messages**: Storico conversazione per contesto (max consigliato: 6 messaggi recenti) + +--- + +## Formato della Risposta (SSE) + +L'endpoint ritorna un flusso di eventi Server-Sent Events. Ogni evento è nel formato: + +``` +data: {JSON object}\n\n +``` + +### Tipi di Eventi + +#### 1. Status Event +```json +{ + "type": "status", + "message": "Processing with MCP tools..." +} +``` + +Indica che l'AI sta elaborando la richiesta. + +#### 2. Tool Call Event +```json +{ + "type": "tool_call", + "tool_name": "add_task", + "tool_args": { + "title": "Riunione team", + "category_name": "Lavoro", + "end_time": "2025-12-15T10:00:00" + }, + "item_index": 0 +} +``` + +Mostra quale strumento MCP l'AI sta chiamando e con quali parametri. + +#### 3. Tool Output Event +```json +{ + "type": "tool_output", + "tool_name": "add_task", + "output": "{\"success\": true, \"type\": \"task_created\", ...}", + "item_index": 1 +} +``` + +Ritorna il risultato dello strumento MCP chiamato. Questo output può contenere dati per la visualizzazione UI. + +#### 4. Content Event (streaming della risposta) +```json +{ + "type": "content", + "delta": "Ho creato il task " +} +``` + +Parte della risposta testuale dell'AI, inviata chunk per chunk (50 caratteri alla volta). + +#### 5. Done Event +```json +{ + "type": "done", + "message": "Stream completed" +} +``` + +Indica che lo streaming è terminato. + +#### 6. Error Event +```json +{ + "type": "error", + "message": "Errore MCP: Database connection failed" +} +``` + +Indica un errore durante l'elaborazione. + +--- + +## Casi d'Uso ed Esempi + +### Caso 1: Creazione di un Task Semplice + +**INPUT:** +```json +{ + "quest": "Crea un task Riunione team per domani alle 10", + "model": "advanced" +} +``` + +**OUTPUT (sequenza eventi SSE):** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "add_task", "tool_args": {"title": "Riunione team", "end_time": "2025-12-16T10:00:00", "priority": "MEDIA"}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "add_task", "output": "{\"success\": true, \"type\": \"task_created\", \"message\": \"✅ Task 'Riunione team' creato con successo\", \"task\": {\"task_id\": 42, \"title\": \"Riunione team\", \"end_time\": \"2025-12-16T10:00:00\", \"priority\": \"MEDIA\", \"status\": \"IN_SOSPESO\"}}", "item_index": 1} + +data: {"type": "content", "delta": "Ho creato il task "} + +data: {"type": "content", "delta": "'Riunione team' per domani alle 10:00"} + +data: {"type": "content", "delta": ". Il task è stato salvato con successo!"} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Mostra notifica di successo: "✅ Task 'Riunione team' creato con successo" +- Mostra bottone "Modifica Riunione team" (grazie a `type: "task_created"`) +- Mostra il testo dell'AI nella chat + +--- + +### Caso 2: Visualizzazione Lista Task + +**INPUT:** +```json +{ + "quest": "Mostrami i miei task di oggi", + "model": "base" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "show_tasks_to_user", "tool_args": {"filter_type": "today"}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "show_tasks_to_user", "output": "{\"type\": \"task_list\", \"version\": \"1.0\", \"tasks\": [{\"id\": 1, \"title\": \"Riunione team\", \"endTimeFormatted\": \"Oggi, 10:00\", \"category\": \"Lavoro\", \"categoryColor\": \"#3B82F6\", \"priority\": \"Alta\", \"priorityEmoji\": \"[!]\", \"priorityColor\": \"#EF4444\", \"status\": \"In sospeso\", \"actions\": {...}}, {...}], \"summary\": {\"total\": 5, \"pending\": 3, \"completed\": 2, \"high_priority\": 1}, \"voice_summary\": \"Hai 5 task oggi, di cui 1 ad alta priorità. 3 sono in sospeso e 2 completati.\", \"ui_hints\": {...}}", "item_index": 1} + +data: {"type": "content", "delta": "Ecco i tuoi task di oggi:"} + +data: {"type": "content", "delta": " hai 5 task, di cui 1 ad alta priorità."} + +data: {"type": "content", "delta": " 3 sono ancora in sospeso."} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Rileva `type: "task_list"` nel tool output +- Renderizza lista formattata con: + - Colori categoria + - Emoji priorità + - Date formattate in italiano + - Bottoni azione (completa, modifica, elimina) + - Raggruppamento per categoria (da `ui_hints.group_by`) +- Se TTS attivo, legge il `voice_summary` + +--- + +### Caso 3: Creazione Categoria con Task + +**INPUT:** +```json +{ + "quest": "Crea una categoria Progetti e aggiungi un task sviluppa app mobile", + "model": "advanced" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "create_category", "tool_args": {"name": "Progetti", "description": "Progetti personali"}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "create_category", "output": "{\"success\": true, \"type\": \"category_created\", \"message\": \"✅ Categoria 'Progetti' creata con successo\", \"category\": {\"category_id\": 10, \"name\": \"Progetti\", \"description\": \"Progetti personali\"}}", "item_index": 1} + +data: {"type": "tool_call", "tool_name": "add_task", "tool_args": {"title": "Sviluppa app mobile", "category_name": "Progetti"}, "item_index": 2} + +data: {"type": "tool_output", "tool_name": "add_task", "output": "{\"success\": true, \"type\": \"task_created\", \"message\": \"✅ Task 'Sviluppa app mobile' creato con successo in 'Progetti'\", \"task\": {\"task_id\": 43, \"title\": \"Sviluppa app mobile\", \"category_id\": 10}, \"category_used\": \"Progetti\"}", "item_index": 3} + +data: {"type": "content", "delta": "Ho creato la categoria 'Progetti'"} + +data: {"type": "content", "delta": " e aggiunto il task 'Sviluppa app mobile'"} + +data: {"type": "content", "delta": " al suo interno!"} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Prima tool output: mostra bottone "Modifica Progetti" (per `type: "category_created"`) +- Seconda tool output: mostra bottone "Modifica Sviluppa app mobile" (per `type: "task_created"`) +- Mostra il testo dell'AI nella chat + +--- + +### Caso 4: Ricerca e Modifica Task + +**INPUT:** +```json +{ + "quest": "Completa il task riunione team", + "model": "advanced" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "get_tasks", "tool_args": {}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "get_tasks", "output": "{\"tasks\": [{\"task_id\": 42, \"title\": \"Riunione team\", \"status\": \"IN_SOSPESO\"}, {...}], \"total\": 10}", "item_index": 1} + +data: {"type": "tool_call", "tool_name": "complete_task", "tool_args": {"task_id": 42}, "item_index": 2} + +data: {"type": "tool_output", "tool_name": "complete_task", "output": "{\"message\": \"Task completato con successo\", \"task_id\": 42}", "item_index": 3} + +data: {"type": "content", "delta": "Ho completato il task 'Riunione team'"} + +data: {"type": "content", "delta": ". Ottimo lavoro!"} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- **NESSUNA visualizzazione** per `get_tasks` (uso interno, no `type` speciale) +- **NESSUNA visualizzazione** per `complete_task` (no `type` speciale) +- Solo il testo dell'AI appare nella chat +- L'app può aggiornare la lista task via polling/refresh + +--- + +### Caso 5: Visualizzazione Categorie con Statistiche + +**INPUT:** +```json +{ + "quest": "Quali categorie ho e quanti task ci sono in ognuna?", + "model": "base" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "show_categories_to_user", "tool_args": {}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "show_categories_to_user", "output": "{\"type\": \"category_list\", \"version\": \"1.0\", \"categories\": [{\"id\": 1, \"name\": \"Lavoro\", \"description\": \"Task di lavoro\", \"color\": \"#3B82F6\", \"icon\": \"briefcase\", \"taskCount\": 12}, {\"id\": 5, \"name\": \"Sport\", \"color\": \"#F59E0B\", \"icon\": \"activity\", \"taskCount\": 3}, {...}], \"summary\": {\"total\": 5, \"categories_with_tasks\": 3, \"total_tasks\": 25}, \"voice_summary\": \"Hai 5 categorie, di cui 3 con task attivi. Totale 25 task.\", \"ui_hints\": {\"display_mode\": \"grid\", \"enable_swipe_actions\": true}}", "item_index": 1} + +data: {"type": "content", "delta": "Hai 5 categorie in totale."} + +data: {"type": "content", "delta": " La categoria 'Lavoro' ha 12 task"} + +data: {"type": "content", "delta": ", mentre 'Sport' ne ha 3."} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Rileva `type: "category_list"` +- Renderizza griglia (da `ui_hints.display_mode: "grid"`) +- Ogni carta categoria mostra: + - Icona (es. briefcase per Lavoro) + - Colore (#3B82F6 per Lavoro) + - Nome categoria + - Conteggio task (12 per Lavoro) + - Bottoni azione (✏️ Modifica, 🗑️ Elimina, 👁️ Vedi task) +- Swipe actions abilitate + +--- + +### Caso 6: Creazione Note + +**INPUT:** +```json +{ + "quest": "Crea una nota con scritto comprare il latte", + "model": "advanced" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "create_note", "tool_args": {"title": "Comprare il latte", "color": "#FFEB3B"}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "create_note", "output": "{\"success\": true, \"type\": \"note_created\", \"message\": \"✅ Nota creata con successo\", \"note\": {\"note_id\": 456, \"title\": \"Comprare il latte\", \"position_x\": \"0\", \"position_y\": \"0\", \"color\": \"#FFEB3B\"}}", "item_index": 1} + +data: {"type": "content", "delta": "Ho creato la nota 'Comprare il latte'"} + +data: {"type": "content", "delta": " con colore giallo."} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Rileva `type: "note_created"` +- Mostra notifica: "✅ Nota creata con successo" +- Mostra bottone "Modifica Comprare il latte" +- Nota avrà colore giallo (#FFEB3B) + +--- + +### Caso 7: Conversazione Multi-Turn con Contesto + +**INPUT (Turn 1):** +```json +{ + "quest": "Crea un task Palestra", + "model": "advanced", + "previous_messages": [] +} +``` + +**INPUT (Turn 2 - con contesto):** +```json +{ + "quest": "Spostalo a domani alle 18", + "model": "advanced", + "previous_messages": [ + { + "role": "user", + "content": "Crea un task Palestra" + }, + { + "role": "assistant", + "content": "Ho creato il task 'Palestra' per oggi." + } + ] +} +``` + +**OUTPUT (Turn 2):** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "get_tasks", "tool_args": {}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "get_tasks", "output": "{\"tasks\": [{\"task_id\": 50, \"title\": \"Palestra\", \"end_time\": \"2025-12-15T12:00:00\"}, {...}]}", "item_index": 1} + +data: {"type": "tool_call", "tool_name": "update_task", "tool_args": {"task_id": 50, "end_time": "2025-12-16T18:00:00"}, "item_index": 2} + +data: {"type": "tool_output", "tool_name": "update_task", "output": "{\"message\": \"Task aggiornato con successo\"}", "item_index": 3} + +data: {"type": "content", "delta": "Ho spostato il task 'Palestra'"} + +data: {"type": "content", "delta": " a domani alle 18:00!"} + +data: {"type": "done", "message": "Stream completed"} +``` + +**SPIEGAZIONE:** +- L'AI usa `previous_messages` per capire che "spostalo" si riferisce al task "Palestra" creato prima +- Cerca il task con `get_tasks()` (uso interno) +- Aggiorna con `update_task()` (uso interno, no visualizzazione UI) +- Solo il messaggio testuale appare nella chat + +--- + +### Caso 8: Visualizzazione Note con Statistiche Colori + +**INPUT:** +```json +{ + "quest": "Mostrami le mie note", + "model": "base" +} +``` + +**OUTPUT:** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "show_notes_to_user", "tool_args": {}, "item_index": 0} + +data: {"type": "tool_output", "tool_name": "show_notes_to_user", "output": "{\"type\": \"note_list\", \"version\": \"1.0\", \"notes\": [{\"id\": 1, \"title\": \"Comprare il latte\", \"color\": \"#FFEB3B\", \"positionX\": \"0\", \"positionY\": \"0\", \"actions\": {...}}, {\"id\": 2, \"title\": \"Chiamare dottore\", \"color\": \"#FF9800\", \"actions\": {...}}, {...}], \"summary\": {\"total\": 15, \"color_counts\": {\"#FFEB3B\": 8, \"#4CAF50\": 5, \"#2196F3\": 2}}, \"voice_summary\": \"Hai 15 note, la maggior parte sono gialle.\", \"ui_hints\": {\"display_mode\": \"grid\", \"enable_drag_and_drop\": true, \"enable_color_picker\": true}}", "item_index": 1} + +data: {"type": "content", "delta": "Hai 15 note in totale."} + +data: {"type": "content", "delta": " 8 sono gialle, 5 verdi e 2 blu."} + +data: {"type": "done", "message": "Stream completed"} +``` + +**VISUALIZZAZIONE APP:** +- Rileva `type: "note_list"` +- Renderizza griglia (da `ui_hints.display_mode: "grid"`) +- Ogni carta nota mostra: + - Titolo + - Colore di sfondo + - Bottoni azione (✏️ Modifica, 🗑️ Elimina, 🎨 Cambia colore) +- Drag & drop abilitato (da `ui_hints.enable_drag_and_drop`) +- Color picker disponibile + +--- + +### Caso 9: Errore - API Key Mancante + +**INPUT:** +```json +{ + "quest": "Crea task Test", + "model": "advanced" +} +``` + +**OUTPUT (se OPENAI_API_KEY non configurata):** + +``` +data: {"type": "error", "message": "Configurazione OpenAI mancante. Controlla la variabile d'ambiente OPENAI_API_KEY."} +``` + +**VISUALIZZAZIONE APP:** +- Mostra messaggio di errore all'utente +- Suggerisce di contattare supporto + +--- + +### Caso 10: Errore - Database Non Raggiungibile + +**INPUT:** +```json +{ + "quest": "Mostrami i task", + "model": "base" +} +``` + +**OUTPUT (se database offline):** + +``` +data: {"type": "status", "message": "Processing with MCP tools..."} + +data: {"type": "tool_call", "tool_name": "show_tasks_to_user", "tool_args": {}, "item_index": 0} + +data: {"type": "error", "message": "Errore MCP: Connection to database failed"} +``` + +**VISUALIZZAZIONE APP:** +- Mostra errore: "Errore durante l'operazione. Riprova più tardi." +- Può mostrare bottone "Riprova" + +--- + +## Sistema di Visualizzazione UI + +### Principio Chiave: Type-Based Detection + +L'app React Native decide **cosa visualizzare** analizzando il campo `type` nel JSON di output dei tool MCP: + +| **Type** | **Azione App** | +|----------|----------------| +| `task_created` | Mostra notifica + bottone "Modifica task" | +| `category_created` | Mostra notifica + bottone "Modifica categoria" | +| `note_created` | Mostra notifica + bottone "Modifica nota" | +| `task_list` | Renderizza lista formattata task | +| `category_list` | Renderizza griglia categorie | +| `note_list` | Renderizza griglia note | +| **Nessun type** | **NESSUNA visualizzazione** (uso interno AI) | + +### Strumenti Interni vs Visualizzazione + +#### Strumenti Interni (NO visualizzazione UI) +- `get_tasks()` - Ritorna: `{tasks: [...], total: N}` +- `get_my_categories()` - Ritorna: `{categories: [...], total: N}` +- `get_notes()` - Ritorna: `{notes: [...], total: N}` +- `update_task()` - Ritorna: `{message: "..."}` +- `complete_task()` - Ritorna: `{message: "..."}` +- `delete_note()` - Ritorna: `{message: "..."}` + +**Uso:** L'AI li usa per cercare, modificare, eliminare dati senza mostrare nulla all'utente. + +#### Strumenti di Visualizzazione (SI visualizzazione UI) +- `show_tasks_to_user()` - Ritorna: `{type: "task_list", ...}` +- `show_categories_to_user()` - Ritorna: `{type: "category_list", ...}` +- `show_notes_to_user()` - Ritorna: `{type: "note_list", ...}` + +**Uso:** L'AI li chiama quando l'utente chiede esplicitamente di vedere/visualizzare qualcosa. + +#### Strumenti di Creazione (Bottone "Modifica") +- `add_task()` - Ritorna: `{type: "task_created", task: {...}}` +- `create_category()` - Ritorna: `{type: "category_created", category: {...}}` +- `create_note()` - Ritorna: `{type: "note_created", note: {...}}` + +**Uso:** Dopo la creazione, l'app mostra automaticamente un bottone per modificare l'elemento appena creato. + +--- + +## Gestione Storico Conversazione + +### Strategia Raccomandata + +1. **Limita a 6 messaggi recenti** nel campo `previous_messages` +2. **Includi solo messaggi rilevanti** (user/assistant, no system) +3. **Non includere tool calls/outputs** nello storico (solo testo conversazionale) + +### Esempio Storico + +```json +{ + "quest": "Completa il task palestra", + "previous_messages": [ + { + "role": "user", + "content": "Crea task Palestra per oggi" + }, + { + "role": "assistant", + "content": "Ho creato il task 'Palestra' per oggi alle 12:00." + }, + { + "role": "user", + "content": "Spostalo a domani" + }, + { + "role": "assistant", + "content": "Ho spostato il task 'Palestra' a domani alle 12:00." + } + ] +} +``` + +--- + +## Best Practices + +### Per gli Sviluppatori Frontend + +1. **Parsing SSE Corretto** + - Ogni evento inizia con `data: ` e termina con `\n\n` + - Parsare il JSON dopo `data: ` + - Gestire eventi parziali/buffer + +2. **Gestione Tool Outputs** + - Quando ricevi `type: "tool_output"`, parsare il campo `output` come JSON + - Controllare il campo `type` nel JSON parsato + - Mostrare UI solo se `type` è uno dei tipi visualizzazione + +3. **Concatenazione Content Delta** + - Gli eventi `type: "content"` arrivano in chunk + - Concatenare `delta` per costruire il messaggio completo + - Mostrare progressivamente nella chat (effetto typing) + +4. **Gestione Errori** + - Su `type: "error"`, mostrare messaggio user-friendly + - Offrire bottone "Riprova" per rifare la richiesta + - Non mostrare stack trace tecnici + +5. **Timeout** + - Impostare timeout di 120 secondi per richieste complesse + - Mostrare indicatore di caricamento durante streaming + - Chiudere connessione SSE su timeout + +### Per gli Sviluppatori Backend + +1. **Tool Selection** + - Usare strumenti interni (`get_*`) per ricerche/modifiche + - Usare strumenti visualizzazione (`show_*`) solo su richiesta esplicita + - Non chiamare `show_*` dopo ogni creazione + +2. **Error Handling** + - Catch tutte le eccezioni nel generator + - Sempre inviare `type: "error"` con messaggio descrittivo + - Non lasciare stream aperti senza `type: "done"` + +3. **Performance** + - Chunk size di 50 caratteri per `content` delta + - `asyncio.sleep(0.01)` tra chunk per evitare overwhelm + - Riusare connessioni HTTP client (pool) + +--- + +## Limitazioni e Considerazioni + +### Limiti Tecnici + +- **Timeout SSE**: 120 secondi (configurato in `httpx.AsyncClient`) +- **Lunghezza Quest**: Consigliato max 500 caratteri (no limite hard) +- **Storico Messaggi**: Consigliato max 6 (più messaggi = più token = più costo) +- **Chunk Size**: 50 caratteri per evento content + +### Costi OpenAI + +- **Model Base** (GPT-3.5-Turbo): ~$0.002 / 1K token +- **Model Advanced** (GPT-4): ~$0.03 / 1K token input, ~$0.06 / 1K token output + +**Raccomandazione**: Usare `model: "base"` per operazioni semplici (70% casi), `advanced` solo per conversazioni complesse. + +### Rate Limiting + +Se vengono inviate troppe richieste: +- OpenAI: 3,500 richieste/min (TPM varia per tier) +- MCP Server: No limite esplicito (dipende da database/API FastAPI) + +--- + +## Troubleshooting + +### Problema: Stream non completa mai + +**Causa**: L'AI non riesce a completare la richiesta (loop infinito, errore non gestito) + +**Soluzione:** +- Controllare i log server per eccezioni +- Verificare che tutti i tool MCP ritornino risposta valida +- Impostare timeout lato client (120s consigliato) + +### Problema: App non mostra UI per lista task + +**Causa**: Tool output non ha campo `type` corretto + +**Soluzione:** +- Verificare che l'AI chiami `show_tasks_to_user()` e NON `get_tasks()` +- Controllare che output abbia `type: "task_list"` +- Verificare parsing JSON lato app + +### Problema: Bottone "Modifica" non appare dopo creazione + +**Causa**: Tool output non ha `type: "X_created"` + +**Soluzione:** +- Verificare che `add_task()` ritorni `type: "task_created"` +- Verificare che app gestisca tipo creazione correttamente +- Controllare che il campo `task`, `category` o `note` sia presente + +### Problema: AI non ricorda contesto conversazione + +**Causa**: `previous_messages` vuoto o malformato + +**Soluzione:** +- Assicurarsi che app invii storico corretto +- Verificare formato: `[{role, content}, ...]` +- Controllare che messaggi siano in ordine cronologico + +--- + +## Riferimenti + +### File Correlati + +- **Endpoint**: [src/app/api/routes/chatbot.py:308](src/app/api/routes/chatbot.py#L308) +- **Schema Request**: [src/app/schemas/chatbot.py](src/app/schemas/chatbot.py) +- **Sistema Visualizzazione**: [UI_VISUALIZATION_SYSTEM.md](UI_VISUALIZATION_SYSTEM.md) +- **Tool MCP Tasks**: [src/tools/tasks.py](src/tools/tasks.py) +- **Tool MCP Categories**: [src/tools/categories.py](src/tools/categories.py) +- **Tool MCP Notes**: [src/tools/notes.py](src/tools/notes.py) +- **Formatters UI**: [src/formatters/tasks.py](src/formatters/tasks.py) + +### Documentazione Esterna + +- **OpenAI Agents SDK**: https://github.com/openai/agents +- **Model Context Protocol (MCP)**: https://modelcontextprotocol.io +- **Server-Sent Events (SSE)**: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events + +--- + +## Changelog + +### v3.0 (2025-01-12) - MCP Agents +- ✅ Migrato da chatbot interno a OpenAI Agents SDK +- ✅ MCP server esterno via SSE transport +- ✅ Sistema visualizzazione basato su `type` field +- ✅ Supporto tool call/output streaming +- ✅ JWT authentication per MCP tools + +### v2.0 (2024-12) - MCP Integration +- ✅ Integrazione MCP tools +- ✅ Chat history con `previous_messages` + +### v1.0 (2024-11) - Initial Release +- ✅ Endpoint base `/chat/text` +- ✅ OpenAI GPT integration +- ✅ SSE streaming + +--- + +## Conclusione + +L'endpoint `/chat/text` è il cuore conversazionale di MyTaskly. Attraverso OpenAI Agents, MCP tools e il sistema di visualizzazione type-based, offre un'esperienza utente fluida e intelligente per gestire task, categorie e note. + +Per domande o supporto, consultare: +- **Issues**: https://github.com/YourOrg/MyTaskly-server/issues +- **Docs**: https://docs.mytasklyapp.com diff --git a/RECURRING_TASKS_CLIENT_API.md b/IA_docs/RECURRING_TASKS_CLIENT_API.md similarity index 100% rename from RECURRING_TASKS_CLIENT_API.md rename to IA_docs/RECURRING_TASKS_CLIENT_API.md diff --git a/category.md b/IA_docs/category.md similarity index 100% rename from category.md rename to IA_docs/category.md diff --git a/package-lock.json b/package-lock.json index 1081d69..c9338df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "react-i18next": "^16.3.5", "react-native": "0.79.5", "react-native-blob-util": "^0.22.2", + "react-native-calendars": "^1.1313.0", "react-native-chat-ui": "^0.1.9", "react-native-dotenv": "^3.4.11", "react-native-edge-to-edge": "1.6.0", @@ -12733,7 +12734,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -13732,6 +13732,16 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", @@ -15171,6 +15181,38 @@ "react-native": "*" } }, + "node_modules/react-native-calendars": { + "version": "1.1313.0", + "resolved": "https://registry.npmjs.org/react-native-calendars/-/react-native-calendars-1.1313.0.tgz", + "integrity": "sha512-YQ7Vg57rBRVymolamYDTxZ0lPOELTDHQbTukTWdxR47aRBYJwKI6ocRbwcY5gYgyDwNgJS4uLGu5AvmYS74LYQ==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.1", + "lodash": "^4.17.15", + "memoize-one": "^5.2.1", + "prop-types": "^15.5.10", + "react-native-safe-area-context": "4.5.0", + "react-native-swipe-gestures": "^1.0.5", + "recyclerlistview": "^4.0.0", + "xdate": "^0.8.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "moment": "^2.29.4" + } + }, + "node_modules/react-native-calendars/node_modules/react-native-safe-area-context": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.5.0.tgz", + "integrity": "sha512-0WORnk9SkREGUg2V7jHZbuN5x4vcxj/1B0QOcXJjdYWrzZHgLcUzYWWIUecUPJh747Mwjt/42RZDOaFn3L8kPQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-chat-ui": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/react-native-chat-ui/-/react-native-chat-ui-0.1.9.tgz", @@ -15388,6 +15430,12 @@ "react-native": "*" } }, + "node_modules/react-native-swipe-gestures": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz", + "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", + "license": "MIT" + }, "node_modules/react-native-vector-icons": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz", @@ -15710,6 +15758,21 @@ "node": ">= 6" } }, + "node_modules/recyclerlistview": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/recyclerlistview/-/recyclerlistview-4.2.3.tgz", + "integrity": "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==", + "license": "Apache-2.0", + "dependencies": { + "lodash.debounce": "4.0.8", + "prop-types": "15.8.1", + "ts-object-utils": "0.0.5" + }, + "peerDependencies": { + "react": ">= 15.2.1", + "react-native": ">= 0.30.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -17587,6 +17650,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-object-utils": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz", + "integrity": "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==", + "license": "ISC" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -18735,6 +18804,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/xdate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/xdate/-/xdate-0.8.3.tgz", + "integrity": "sha512-1NhJWPJwN+VjbkACT9XHbQK4o6exeSVtS2CxhMPwUE7xQakoEFTlwra9YcqV/uHQVyeEUYoYC46VGDJ+etnIiw==", + "license": "(MIT OR GPL-2.0)" + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 4ed9416..3329e34 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "react-i18next": "^16.3.5", "react-native": "0.79.5", "react-native-blob-util": "^0.22.2", + "react-native-calendars": "^1.1313.0", "react-native-chat-ui": "^0.1.9", "react-native-dotenv": "^3.4.11", "react-native-edge-to-edge": "1.6.0", diff --git a/reset-welcome.js b/reset-welcome.js deleted file mode 100644 index b0a6336..0000000 --- a/reset-welcome.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Script per resettare il Welcome Carousel - * Esegui: node reset-welcome.js - * - * OPPURE usa direttamente nell'app React Native DevTools: - * AsyncStorage.removeItem('@mytaskly:welcome_carousel_completed') - */ - -console.log('📝 Per resettare il Welcome Carousel:'); -console.log(''); -console.log('1. Apri l\'app in Expo'); -console.log('2. Shake il dispositivo (o Cmd+D su iOS, Cmd+M su Android)'); -console.log('3. Apri "Debug Remote JS"'); -console.log('4. Nella console del browser, esegui:'); -console.log(''); -console.log(' AsyncStorage.removeItem(\'@mytaskly:welcome_carousel_completed\').then(() => {'); -console.log(' console.log(\'✅ Welcome carousel resetted! Riavvia l\'app.\');'); -console.log(' });'); -console.log(''); -console.log('5. Chiudi e riapri l\'app per vedere il carousel'); -console.log(''); -console.log('OPPURE:'); -console.log(''); -console.log('Disinstalla e reinstalla l\'app per vedere il flusso first-time completo.'); diff --git a/src/components/BotChat/MessageBubble.tsx b/src/components/BotChat/MessageBubble.tsx index 665844e..c6f9aad 100644 --- a/src/components/BotChat/MessageBubble.tsx +++ b/src/components/BotChat/MessageBubble.tsx @@ -1,14 +1,47 @@ -import React, { useEffect, useRef } from 'react'; -import { StyleSheet, View, Text, Animated } from 'react-native'; -import { MessageBubbleProps } from './types'; -import TaskTableBubble from './TaskTableBubble'; // Importa il nuovo componente +import React, { useEffect, useRef, useState } from 'react'; +import { StyleSheet, View, Text, Animated, Alert } from 'react-native'; +import { MessageBubbleProps, ToolWidget, TaskItem } from './types'; +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 VisualizationModal from './widgets/VisualizationModal'; +import ItemDetailModal from './widgets/ItemDetailModal'; +import TaskEditModal from '../Task/TaskEditModal'; +import EditCategoryModal from '../Category/EditCategoryModal'; +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 isBot = message.sender === 'bot'; const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(20)).current; + // Stati per le modals + const [visualizationModalVisible, setVisualizationModalVisible] = useState(false); + const [selectedVisualizationWidget, setSelectedVisualizationWidget] = useState(null); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedItemType, setSelectedItemType] = useState<'task' | 'category' | 'note'>('task'); + + // Stato per task list modal (dalla TaskListBubble) + const [taskListModalVisible, setTaskListModalVisible] = useState(false); + const [taskListForModal, setTaskListForModal] = useState([]); + + // Stato per task edit modal + const [taskEditModalVisible, setTaskEditModalVisible] = useState(false); + const [selectedTaskForEdit, setSelectedTaskForEdit] = useState(null); + + // Stato per category edit modal e menu + const [categoryEditModalVisible, setCategoryEditModalVisible] = useState(false); + const [categoryMenuVisible, setCategoryMenuVisible] = useState(false); + const [selectedCategoryForEdit, setSelectedCategoryForEdit] = useState(null); + const [editCategoryName, setEditCategoryName] = useState(''); + const [editCategoryDescription, setEditCategoryDescription] = useState(''); + const [isEditingCategory, setIsEditingCategory] = useState(false); + const [isDeletingCategory, setIsDeletingCategory] = useState(false); + // Animazioni per i punti di streaming const streamingDot1 = useRef(new Animated.Value(0.5)).current; const streamingDot2 = useRef(new Animated.Value(0.5)).current; @@ -74,27 +107,67 @@ const MessageBubble: React.FC = ({ message, style }) => { const formatTime = (date: Date) => { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; + + // Handler per "View All" dalla TaskListBubble + const handleViewAllTasks = (tasks: TaskItem[]) => { + // Converti TaskItem[] a formato compatibile con VisualizationModal + const taskListItems = tasks.map(task => ({ + id: task.task_id, + title: task.title, + endTimeFormatted: task.end_time, + end_time: task.end_time, + category: task.category, + category_name: task.category, + categoryColor: '#007AFF', + priority: task.priority, + priorityEmoji: '', + priorityColor: '#000000', + status: task.status, + completed: task.status === 'Completato', + })); + + // Crea un widget fittizio per la modal + const fakeWidget: ToolWidget = { + id: 'task-list-view-all', + toolName: 'show_tasks_to_user', + status: 'success', + itemIndex: 0, + toolOutput: { + type: 'task_list', + tasks: taskListItems, + summary: { + total: tasks.length, + pending: tasks.filter(t => t.status !== 'Completato').length, + completed: tasks.filter(t => t.status === 'Completato').length, + } + } + }; + + setSelectedVisualizationWidget(fakeWidget); + setVisualizationModalVisible(true); + }; + // Controlla se il messaggio del bot contiene la struttura dei task specificata if (isBot && typeof message.text === 'string') { // Controlla se il messaggio contiene un JSON array di task o il messaggio "Nessun task trovato" - if ((message.text.includes('[') && message.text.includes(']') && + if ((message.text.includes('[') && message.text.includes(']') && (message.text.includes('📅 TASK PER LA DATA') || message.text.includes('task_id'))) || message.text.includes('📅 Nessun task trovato') || (message.text.includes('📅') && message.text.includes('TASK PER LA DATA'))) { - return ; + return ; } // Controlla il formato JSON legacy try { const parsedData = JSON.parse(message.text); if (parsedData.mode === "view") { - // Se ha una proprietà message, usa quella per il TaskTableBubble + // Se ha una proprietà message, usa quella per il TaskListBubble if (parsedData.message) { - return ; + return ; } // Altrimenti, se ha tasks, converte al nuovo formato if (parsedData.tasks) { const legacyMessage = `Ecco i tuoi impegni:\n📅 TASK:\n${JSON.stringify(parsedData.tasks)}\n📊 Totale task trovati: ${parsedData.tasks.length}`; - return ; + return ; } } } catch { @@ -102,10 +175,10 @@ const MessageBubble: React.FC = ({ message, style }) => { } } - // Se il messaggio del bot contiene attività (formato legacy), visualizza TaskTableBubble + // Se il messaggio del bot contiene attività (formato legacy), visualizza TaskListBubble if (isBot && message.tasks && message.tasks.length > 0) { const legacyMessage = `Ecco i tuoi impegni:\n📅 TASK:\n${JSON.stringify(message.tasks)}\n📊 Totale task trovati: ${message.tasks.length}`; - return ; + return ; } // Altrimenti, visualizza il messaggio di testo normale // Funzione per renderizzare il contenuto del messaggio @@ -148,6 +221,134 @@ const MessageBubble: React.FC = ({ message, style }) => { } }; + // Handler per aprire la modal di visualizzazione + const handleOpenVisualization = (widget: ToolWidget) => { + setSelectedVisualizationWidget(widget); + setVisualizationModalVisible(true); + }; + + // Handler per aprire la modal di dettaglio di un item + const handleOpenItemDetail = (item: any, type: 'task' | 'category' | 'note') => { + setSelectedItem(item); + setSelectedItemType(type); + setDetailModalVisible(true); + }; + + // Handler per aprire la modal di modifica task + const handleTaskPress = (task: TaskType) => { + setSelectedTaskForEdit(task); + setTaskEditModalVisible(true); + }; + + // Handler per salvare le modifiche al task + const handleSaveTask = async (editedTask: Partial) => { + if (!selectedTaskForEdit) return; + + try { + await updateTask(selectedTaskForEdit.task_id, editedTask); + setTaskEditModalVisible(false); + setSelectedTaskForEdit(null); + // Opzionalmente, puoi aggiornare il messaggio nella chat o mostrare una notifica + } catch (error) { + console.error('[MessageBubble] Error updating task:', error); + // Opzionalmente, mostra un messaggio di errore all'utente + } + }; + + // Handler per aprire il menu della categoria + const handleCategoryPress = (category: any) => { + console.log('[MessageBubble] handleCategoryPress called with:', category); + setSelectedCategoryForEdit(category); + setCategoryMenuVisible(true); + }; + + // Handler per chiudere il menu della categoria + const handleCloseCategoryMenu = () => { + setCategoryMenuVisible(false); + }; + + // Handler per aprire la modal di modifica dalla voce menu + const handleEditCategory = () => { + console.log('[MessageBubble] handleEditCategory - selectedCategoryForEdit:', selectedCategoryForEdit); + console.log('[MessageBubble] handleEditCategory - name:', selectedCategoryForEdit?.name); + console.log('[MessageBubble] handleEditCategory - description:', selectedCategoryForEdit?.description); + setCategoryMenuVisible(false); + setEditCategoryName(selectedCategoryForEdit?.name || ''); + setEditCategoryDescription(selectedCategoryForEdit?.description || ''); + setCategoryEditModalVisible(true); + }; + + // Handler per salvare le modifiche alla categoria + const handleSaveCategory = async () => { + if (!selectedCategoryForEdit) return; + + if (editCategoryName.trim() === '') { + Alert.alert('Errore', 'Il nome della categoria non può essere vuoto'); + return; + } + + setIsEditingCategory(true); + try { + await updateCategory(selectedCategoryForEdit.name, { + name: editCategoryName.trim(), + description: editCategoryDescription.trim() + }); + + Alert.alert('Successo', 'Categoria aggiornata con successo'); + setCategoryEditModalVisible(false); + setSelectedCategoryForEdit(null); + } catch (error) { + console.error('[MessageBubble] Error updating category:', error); + Alert.alert('Errore', 'Impossibile aggiornare la categoria. Riprova più tardi.'); + } finally { + setIsEditingCategory(false); + } + }; + + // Handler per chiudere la modal di modifica categoria + const handleCancelCategoryEdit = () => { + setCategoryEditModalVisible(false); + setEditCategoryName(''); + setEditCategoryDescription(''); + }; + + // Handler per eliminare una categoria + const handleDeleteCategory = async () => { + if (!selectedCategoryForEdit) return; + + Alert.alert( + 'Conferma eliminazione', + `Sei sicuro di voler eliminare la categoria "${selectedCategoryForEdit.name}"?`, + [ + { text: 'Annulla', style: 'cancel' }, + { + text: 'Elimina', + style: 'destructive', + onPress: async () => { + setIsDeletingCategory(true); + try { + await deleteCategory(selectedCategoryForEdit.name); + Alert.alert('Successo', 'Categoria eliminata con successo'); + setCategoryMenuVisible(false); + setSelectedCategoryForEdit(null); + } catch (error) { + console.error('[MessageBubble] Error deleting category:', error); + Alert.alert('Errore', 'Impossibile eliminare la categoria. Riprova più tardi.'); + } finally { + setIsDeletingCategory(false); + } + }, + }, + ] + ); + }; + + // Handler placeholder per condivisione categoria + const handleShareCategory = () => { + setCategoryMenuVisible(false); + Alert.alert('Info', 'Funzionalità di condivisione non ancora disponibile nella chat'); + }; + return ( = ({ message, style }) => { transform: [{ translateY: slideAnim }] } ]}> + {/* WIDGETS SOPRA AL MESSAGGIO (come richiesto dall'utente) */} + {isBot && message.toolWidgets && message.toolWidgets.length > 0 && ( + + {message.toolWidgets.map((widget) => ( + + ))} + + )} + + {/* BUBBLE DEL MESSAGGIO */} = ({ message, style }) => { )} + {formatTime(message.start_time)} + + {/* MODALS */} + {selectedVisualizationWidget && ( + setVisualizationModalVisible(false)} + onItemPress={handleOpenItemDetail} + onCategoryPress={handleCategoryPress} + /> + )} + + {selectedItem && ( + setDetailModalVisible(false)} + /> + )} + + {selectedTaskForEdit && ( + { + setTaskEditModalVisible(false); + setSelectedTaskForEdit(null); + }} + onSave={handleSaveTask} + /> + )} + + {/* Category Menu */} + {selectedCategoryForEdit && ( + + )} + + {/* Category Edit Modal */} + {selectedCategoryForEdit && ( + + )} ); }; @@ -197,6 +476,10 @@ const styles = StyleSheet.create({ botMessageContainer: { alignItems: 'flex-start', }, + widgetsContainer: { + marginBottom: 8, + width: '100%', + }, messageBubble: { maxWidth: '85%', paddingHorizontal: 18, diff --git a/src/components/BotChat/TaskListBubble.tsx b/src/components/BotChat/TaskListBubble.tsx new file mode 100644 index 0000000..d8ad06e --- /dev/null +++ b/src/components/BotChat/TaskListBubble.tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, Animated, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { TaskTableBubbleProps, TaskItem } from './types'; + +interface TaskListBubbleProps extends TaskTableBubbleProps { + onViewAll?: (tasks: TaskItem[]) => void; +} + +const TaskListBubble: React.FC = ({ message, style, onViewAll }) => { + const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(20)).current; + const [tasks, setTasks] = useState([]); + + // Animazione di entrata + useEffect(() => { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(); + }, [fadeAnim, slideAnim]); + + // Funzione per estrarre il JSON dal messaggio + const extractTasksFromMessage = (text: string): TaskItem[] => { + try { + // Trova l'inizio e la fine del JSON array + const jsonStartIndex = text.indexOf('['); + const jsonEndIndex = text.lastIndexOf(']') + 1; + + if (jsonStartIndex === -1 || jsonEndIndex === 0) { + return []; + } + + // Estrae la stringa JSON + let jsonString = text.substring(jsonStartIndex, jsonEndIndex); + + // Pulisce la stringa JSON rimuovendo i caratteri di escape + jsonString = jsonString.replace(/\\"/g, '"'); + jsonString = jsonString.replace(/\\n/g, '\n'); + jsonString = jsonString.replace(/\\t/g, '\t'); + jsonString = jsonString.replace(/\\\\/g, '\\'); + + // Parse del JSON + const parsedTasks = JSON.parse(jsonString); + + return Array.isArray(parsedTasks) ? parsedTasks : []; + } catch (error) { + console.error('Errore nell\'estrazione dei task dal messaggio:', error); + + // Tentativo alternativo: prova a fare un doppio parse se sembra essere una stringa JSON escaped + try { + const jsonStart = text.indexOf('['); + const jsonEnd = text.lastIndexOf(']') + 1; + let rawJsonString = text.substring(jsonStart, jsonEnd); + + // Se contiene ancora escape, prova JSON.parse doppio + if (rawJsonString.includes('\\"')) { + const parsed = JSON.parse('"' + rawJsonString + '"'); + const parsedTasks = JSON.parse(parsed); + return Array.isArray(parsedTasks) ? parsedTasks : []; + } + } catch (secondError) { + console.error('Anche il tentativo alternativo è fallito:', secondError); + } + + return []; + } + }; + + useEffect(() => { + const extractedTasks = extractTasksFromMessage(message); + setTasks(extractedTasks); + }, [message]); + + // Controlla se il messaggio indica "Nessun task trovato" + const isEmptyTaskMessage = message.includes('📅 Nessun task trovato') || + message.includes('Nessun task trovato') || + message.includes('TASK PER LA DATA'); + + // Se non ci sono task e il messaggio non è di tipo task, non mostrare nulla + if ((!tasks || tasks.length === 0) && !isEmptyTaskMessage) { + return null; + } + + // Estrae il titolo dal messaggio se disponibile + const extractTitle = (text: string): string => { + const lines = text.split('\n'); + const firstLine = lines[0]?.trim(); + if (firstLine && firstLine.startsWith('Ecco')) { + return firstLine; + } + return 'Elenco Impegni'; + }; + + const title = extractTitle(message); + + // Mostra max 3 task come preview + const MAX_PREVIEW_TASKS = 3; + const previewTasks = tasks.slice(0, MAX_PREVIEW_TASKS); + const hasMoreTasks = tasks.length > MAX_PREVIEW_TASKS; + + // Ottieni colore priorità + const getPriorityColor = (priority: string): string => { + const priorityColors: Record = { + 'Alta': '#000000', + 'Media': '#333333', + 'Bassa': '#666666', + }; + return priorityColors[priority] || '#999999'; + }; + + // Formatta la data + const formatDate = (dateString: string): string => { + try { + const date = new Date(dateString); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const isToday = date.toDateString() === today.toDateString(); + const isTomorrow = date.toDateString() === tomorrow.toDateString(); + + if (isToday) { + return `Oggi, ${date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`; + } else if (isTomorrow) { + return `Domani, ${date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`; + } else { + return date.toLocaleDateString('it-IT', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + } + } catch { + return dateString; + } + }; + + // Ottieni icona stato + const getStatusIcon = (status: string) => { + if (status === 'Completato') { + return ; + } + return ; + }; + + return ( + + + {title} + {tasks.length > 0 && ( + + {tasks.length} + + )} + + + {/* Se non ci sono task, mostra messaggio */} + {tasks.length === 0 ? ( + + + + {message.includes('📅 Nessun task trovato') + ? message.split('📅')[1]?.trim() || 'Nessun task trovato per questa data' + : 'Nessun task trovato per questa data' + } + + + ) : ( + <> + {/* Preview Task Cards */} + + {previewTasks.map((task, index) => { + const priorityColor = getPriorityColor(task.priority); + + return ( + + + + {getStatusIcon(task.status)} + + {task.title} + + + + + + {task.category && ( + + + {task.category} + + )} + + {task.end_time && ( + + + {formatDate(task.end_time)} + + )} + + + {task.priority && ( + + + {task.priority} + + )} + + ); + })} + + + {/* View All Button */} + {hasMoreTasks && ( + onViewAll && onViewAll(tasks)} + > + + Visualizza tutti ({tasks.length}) + + + + )} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#ffffff', + borderRadius: 16, + padding: 16, + marginVertical: 8, + marginHorizontal: 20, + borderColor: '#f0f0f0', + borderWidth: 1, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, + maxWidth: '85%', + alignSelf: 'flex-start', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + title: { + fontSize: 16, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + flex: 1, + }, + countBadge: { + backgroundColor: '#f8f8f8', + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + marginLeft: 8, + }, + countText: { + fontSize: 12, + fontWeight: '600', + color: '#666666', + fontFamily: 'System', + }, + taskCardsContainer: { + gap: 12, + }, + taskCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: '#f0f0f0', + }, + taskCardHeader: { + marginBottom: 8, + }, + taskTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + taskTitle: { + fontSize: 15, + fontWeight: '500', + color: '#000000', + fontFamily: 'System', + flex: 1, + }, + taskCardMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + marginBottom: 6, + }, + categoryBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#f8f8f8', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + gap: 4, + }, + categoryText: { + fontSize: 12, + color: '#666666', + fontWeight: '400', + fontFamily: 'System', + }, + timeBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + timeText: { + fontSize: 12, + color: '#666666', + fontWeight: '300', + fontFamily: 'System', + }, + priorityRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + priorityDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + priorityText: { + fontSize: 11, + color: '#999999', + fontWeight: '400', + fontFamily: 'System', + }, + viewAllButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F2F2F7', + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + marginTop: 12, + gap: 6, + }, + viewAllText: { + fontSize: 14, + fontWeight: '600', + color: '#007AFF', + fontFamily: 'System', + }, + emptyMessageContainer: { + paddingVertical: 32, + paddingHorizontal: 20, + alignItems: 'center', + justifyContent: 'center', + }, + emptyMessage: { + fontSize: 14, + color: '#8E8E93', + textAlign: 'center', + fontFamily: 'System', + lineHeight: 20, + marginTop: 12, + }, +}); + +export default TaskListBubble; diff --git a/src/components/BotChat/index.ts b/src/components/BotChat/index.ts index f7b9904..d4e8ae9 100644 --- a/src/components/BotChat/index.ts +++ b/src/components/BotChat/index.ts @@ -4,6 +4,7 @@ import ChatInput from './ChatInput'; import ChatList from './ChatList'; import VoiceRecordButton from './VoiceRecordButton'; import MarkdownExample from './MarkdownExample'; +import TaskListBubble from './TaskListBubble'; import { Message, ChatSession } from './types'; // Esporto tutti i componenti e i tipi per facilitare l'import @@ -13,6 +14,7 @@ export { ChatList, VoiceRecordButton, MarkdownExample, + TaskListBubble, Message, ChatSession }; diff --git a/src/components/BotChat/types.ts b/src/components/BotChat/types.ts index 5e7b06e..86fb99f 100644 --- a/src/components/BotChat/types.ts +++ b/src/components/BotChat/types.ts @@ -11,6 +11,7 @@ export interface Message { tasks?: TaskItem[]; // Array opzionale di attività isStreaming?: boolean; // Indica se il messaggio è ancora in streaming isComplete?: boolean; // Indica se il messaggio streaming è completato + toolWidgets?: ToolWidget[]; // Array di widget tool per visualizzare risultati chiamate MCP } // Interfaccia per gli elementi delle attività @@ -29,6 +30,7 @@ export interface TaskItem { export interface TaskTableBubbleProps { message: string; // Il messaggio completo contenente il JSON style?: StyleProp; + onViewAll?: (tasks: TaskItem[]) => void; // Callback per visualizzare tutti i task } // Interfaccia per la sessione di chat @@ -67,3 +69,161 @@ export interface ChatListProps { messages: Message[]; style?: StyleProp; } + +// ========== WIDGET INTERFACES ========== + +// Widget singolo che rappresenta una chiamata tool MCP +export interface ToolWidget { + id: string; // Identificatore univoco: tool_name + item_index + toolName: string; // Nome del tool (es: "add_task", "show_tasks_to_user") + status: 'loading' | 'success' | 'error'; + itemIndex: number; // Indice del tool nell'ordine di esecuzione + + // Dati da evento tool_call + toolArgs?: any; // Argomenti passati al tool + + // Dati da evento tool_output (JSON parsato) + toolOutput?: ToolOutputData; // Output del tool parsato + errorMessage?: string; // Messaggio di errore se status === 'error' +} + +// Output parsato dal tool MCP +export interface ToolOutputData { + type?: 'task_created' | 'category_created' | 'note_created' | + 'task_list' | 'category_list' | 'note_list'; + success?: boolean; + message?: string; + + // Dati per tool di creazione + task?: { + task_id: number; + title: string; + description?: string; + start_time?: string; + end_time?: string; + priority?: string; + status?: string; + category_id?: number; + category_name?: string; + }; + category?: { + category_id: number; + name: string; + description?: string; + color?: string; + }; + note?: { + note_id: number; + title: string; + color: string; + position_x?: string; + position_y?: string; + }; + + // Dati per tool di visualizzazione + tasks?: TaskListItem[]; + categories?: CategoryListItem[]; + notes?: NoteListItem[]; + + // Metadati per visualizzazione + summary?: { + total: number; + pending?: number; + completed?: number; + high_priority?: number; + categories_with_tasks?: number; + total_tasks?: number; + }; + voice_summary?: string; + ui_hints?: { + display_mode?: 'list' | 'grid' | 'calendar'; + group_by?: string; + enable_swipe_actions?: boolean; + enable_drag_and_drop?: boolean; + enable_color_picker?: boolean; + }; +} + +// Task da lista visualizzazione +export interface TaskListItem { + id: number; + title: string; + endTimeFormatted: string; // Data formattata (es: "Oggi, 10:00") + end_time?: string; // Data ISO originale per filtraggio calendario + category: string; + category_name?: string; // Nome categoria (alternativo a category) + categoryColor: string; + priority: string; + priorityEmoji: string; + priorityColor: string; + status: string; + completed?: boolean; + actions?: { + complete?: boolean; + edit?: boolean; + delete?: boolean; + }; +} + +// Categoria da lista visualizzazione +export interface CategoryListItem { + id: number; + name: string; + description?: string; + color?: string; + icon?: string; + taskCount?: number; + task_count?: number; // Backward compatibility + imageUrl?: string; // Alternative to icon + isShared?: boolean; + isOwned?: boolean; + ownerName?: string; + permissionLevel?: "READ_ONLY" | "READ_WRITE"; +} + +// Nota da lista visualizzazione +export interface NoteListItem { + id: number; + title: string; + color: string; + positionX?: string; + positionY?: string; + actions?: { + edit?: boolean; + delete?: boolean; + changeColor?: boolean; + }; +} + +// Props per WidgetBubble +export interface WidgetBubbleProps { + widget: ToolWidget; + onOpenVisualization?: (widget: ToolWidget) => void; + onOpenItemDetail?: (item: any, type: 'task' | 'category' | 'note') => void; + onTaskPress?: (task: any) => void; + onCategoryPress?: (category: any) => void; +} + +// Props per CreationWidgetCard +export interface CreationWidgetCardProps { + widget: ToolWidget; + onPress?: () => void; +} + +// Props per VisualizationModal +export interface VisualizationModalProps { + visible: boolean; + widget: ToolWidget; + onClose: () => void; + onItemPress?: (item: any, type: 'task' | 'category' | 'note') => void; + onCategoryPress?: (category: any) => void; +} + +// Props per ItemDetailModal +export interface ItemDetailModalProps { + visible: boolean; + item: any; + itemType: 'task' | 'category' | 'note'; + onClose: () => void; + onAction?: (action: string, item: any) => void; +} diff --git a/src/components/BotChat/widgets/CreationWidgetCard.tsx b/src/components/BotChat/widgets/CreationWidgetCard.tsx new file mode 100644 index 0000000..ba0f53d --- /dev/null +++ b/src/components/BotChat/widgets/CreationWidgetCard.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useRef } from 'react'; +import { StyleSheet, View, Text, Animated, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { CreationWidgetCardProps } from '../types'; +import { Ionicons } from '@expo/vector-icons'; + +/** + * Widget inline per creazione task/categoria/nota + * Stati: loading → success | error + */ +const CreationWidgetCard: React.FC = ({ widget, onPress }) => { + const fadeAnim = useRef(new Animated.Value(0)).current; + const scaleAnim = useRef(new Animated.Value(0.9)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(scaleAnim, { + toValue: 1, + friction: 8, + tension: 40, + useNativeDriver: true, + }), + ]).start(); + }, [fadeAnim, scaleAnim]); + + // Determina il contenuto in base allo stato + const renderContent = () => { + if (widget.status === 'loading') { + return ( + + + + Creazione in corso... + + {widget.toolName === 'add_task' && 'Sto creando il task'} + {widget.toolName === 'create_category' && 'Sto creando la categoria'} + {widget.toolName === 'create_note' && 'Sto creando la nota'} + + + + ); + } + + if (widget.status === 'error') { + return ( + + + + Errore + {widget.errorMessage || 'Si è verificato un errore'} + + + ); + } + + // Success state + const output = widget.toolOutput; + if (!output) return null; + + let title = ''; + let subtitle = ''; + let icon: keyof typeof Ionicons.glyphMap = 'checkmark-circle'; + + // Parse toolArgs per ottenere i dati originali della richiesta + let toolArgsData: any = {}; + if (widget.toolArgs) { + try { + toolArgsData = JSON.parse(widget.toolArgs); + } catch (e) { + console.error('[CreationWidgetCard] Error parsing toolArgs:', e); + } + } + + if (output.type === 'task_created') { + // Usa il titolo dai toolArgs (dati originali della richiesta) + title = toolArgsData.title || 'Task creato'; + + // Usa la categoria o end_time per il subtitle + if (output.category_used) { + subtitle = `Categoria: ${output.category_used}`; + } else if (toolArgsData.end_time) { + subtitle = new Date(toolArgsData.end_time).toLocaleDateString('it-IT', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + } else { + subtitle = 'Nessuna scadenza'; + } + icon = 'checkmark-circle'; + } else if (output.type === 'category_created') { + // Per categoria, usa il nome dai toolArgs o dall'output + title = output.category?.name || toolArgsData.name || 'Categoria creata'; + subtitle = 'Categoria creata'; + icon = 'folder'; + } else if (output.type === 'note_created') { + // Per nota, usa il titolo dai toolArgs o dall'output + title = output.note?.title || toolArgsData.title || 'Nota creata'; + subtitle = 'Nota creata'; + icon = 'document-text'; + } + + return ( + + + + {title} + {subtitle} + + + + ); + }; + + // Colore bordo in base allo stato + const borderColor = + widget.status === 'loading' ? '#666666' : + widget.status === 'error' ? '#FF3B30' : + '#000000'; + + return ( + + {renderContent()} + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + borderLeftWidth: 4, + paddingHorizontal: 16, + paddingVertical: 14, + marginBottom: 8, + borderWidth: 1.5, + borderColor: '#E1E5E9', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, + maxWidth: '85%', + }, + contentRow: { + flexDirection: 'row', + alignItems: 'center', + }, + icon: { + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: '500', + color: '#000000', + marginBottom: 2, + fontFamily: 'System', + letterSpacing: -0.3, + }, + subtitle: { + fontSize: 13, + color: '#666666', + fontFamily: 'System', + fontWeight: '300', + }, + errorText: { + color: '#FF3B30', + }, +}); + +export default CreationWidgetCard; diff --git a/src/components/BotChat/widgets/ItemDetailModal.tsx b/src/components/BotChat/widgets/ItemDetailModal.tsx new file mode 100644 index 0000000..8d4c635 --- /dev/null +++ b/src/components/BotChat/widgets/ItemDetailModal.tsx @@ -0,0 +1,404 @@ +import React, { useState } from 'react'; +import { + StyleSheet, + View, + Text, + Modal, + TouchableOpacity, + ScrollView, + SafeAreaView, + Alert, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ItemDetailModalProps } from '../types'; +import * as taskService from '../../../services/taskService'; + +/** + * Modal di dettaglio per task/categoria/nota con azioni (Complete, Edit, Delete, Reschedule) + */ +const ItemDetailModal: React.FC = ({ + visible, + item, + itemType, + onClose, +}) => { + const [isLoading, setIsLoading] = useState(false); + + // Handler per completare un task + const handleCompleteTask = async () => { + if (itemType !== 'task') return; + + setIsLoading(true); + try { + await taskService.updateTask(item.task_id, { + ...item, + status: item.completed ? 'pending' : 'completed', + }); + Alert.alert('Successo', `Task ${item.completed ? 'riaperto' : 'completato'}`); + onClose(); + } catch (error: any) { + Alert.alert('Errore', error.message || 'Impossibile aggiornare il task'); + } finally { + setIsLoading(false); + } + }; + + // Handler per eliminare un item + const handleDelete = () => { + Alert.alert( + 'Conferma eliminazione', + `Sei sicuro di voler eliminare ${itemType === 'task' ? 'questo task' : itemType === 'category' ? 'questa categoria' : 'questa nota'}?`, + [ + { text: 'Annulla', style: 'cancel' }, + { + text: 'Elimina', + style: 'destructive', + onPress: async () => { + setIsLoading(true); + try { + if (itemType === 'task') { + await taskService.deleteTask(item.task_id); + } + // TODO: Implementa delete per categorie e note se necessario + Alert.alert('Successo', 'Elemento eliminato'); + onClose(); + } catch (error: any) { + Alert.alert('Errore', error.message || 'Impossibile eliminare'); + } finally { + setIsLoading(false); + } + }, + }, + ] + ); + }; + + // Renderizza i dettagli in base al tipo + const renderDetails = () => { + if (itemType === 'task') { + return ( + + + + Titolo + + {item.title} + + {item.description && ( + <> + + + Descrizione + + {item.description} + + )} + + {item.end_time && ( + <> + + + Scadenza + + + {new Date(item.end_time).toLocaleString('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + + )} + + {item.category_name && ( + <> + + + Categoria + + + {item.category_name} + + + )} + + {item.priority && ( + <> + + + Priorità + + {item.priority} + + )} + + + + Stato + + + {item.completed ? 'Completato' : 'In sospeso'} + + + ); + } + + if (itemType === 'category') { + return ( + + + + Nome + + {item.name} + + {item.task_count !== undefined && ( + <> + + + Task associati + + {item.task_count} + + )} + + ); + } + + if (itemType === 'note') { + return ( + + + + Titolo + + {item.title} + + {item.content && ( + <> + + + Contenuto + + {item.content} + + )} + + ); + } + + return null; + }; + + return ( + + + {/* HEADER */} + + + + + Dettagli + + + + + {/* ICON GRANDE */} + + + + + + + {/* DETTAGLI */} + {renderDetails()} + + {/* LOADING OVERLAY */} + {isLoading && ( + + + + )} + + + {/* ACTION BUTTONS */} + + {itemType === 'task' && ( + + + + {item.completed ? 'Riapri' : 'Completa'} + + + )} + + + + Elimina + + + + + ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F2F2F7', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 16, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E5EA', + }, + closeButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#000000', + }, + scrollView: { + flex: 1, + }, + iconContainer: { + alignItems: 'center', + paddingVertical: 32, + }, + iconCircle: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#E5F1FF', + alignItems: 'center', + justifyContent: 'center', + }, + detailsContainer: { + backgroundColor: '#FFFFFF', + marginHorizontal: 16, + borderRadius: 12, + padding: 20, + marginBottom: 20, + }, + detailRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + detailRowSpacing: { + marginTop: 20, + }, + detailLabel: { + fontSize: 14, + fontWeight: '600', + color: '#8E8E93', + marginLeft: 8, + }, + detailValue: { + fontSize: 16, + color: '#000000', + lineHeight: 24, + }, + completedText: { + color: '#34C759', + fontWeight: '600', + }, + categoryBadge: { + alignSelf: 'flex-start', + backgroundColor: '#E5F1FF', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 6, + }, + categoryBadgeText: { + fontSize: 14, + fontWeight: '600', + color: '#007AFF', + }, + actionsContainer: { + paddingHorizontal: 16, + paddingVertical: 20, + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E5EA', + gap: 12, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + borderRadius: 12, + gap: 8, + }, + primaryButton: { + backgroundColor: '#007AFF', + }, + destructiveButton: { + backgroundColor: '#FF3B30', + }, + actionButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default ItemDetailModal; diff --git a/src/components/BotChat/widgets/VisualizationModal.tsx b/src/components/BotChat/widgets/VisualizationModal.tsx new file mode 100644 index 0000000..bd02102 --- /dev/null +++ b/src/components/BotChat/widgets/VisualizationModal.tsx @@ -0,0 +1,899 @@ +import React, { useState, useMemo } from 'react'; +import { + StyleSheet, + View, + Text, + Modal, + TouchableOpacity, + ScrollView, + SafeAreaView, + TextInput, + Image, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { VisualizationModalProps, TaskListItem } from '../types'; +import Task from '../../Task/Task'; +import { Task as TaskType } from '../../../services/taskService'; +import CalendarGrid from '../../Calendar/CalendarGrid'; +import dayjs from 'dayjs'; + +/** + * Modal full-screen per visualizzare liste di task/categorie/note + * Include calendario in alto (solo per task) e lista scrollabile sotto + */ +const VisualizationModal: React.FC = ({ + visible, + widget, + onClose, + onItemPress, + onCategoryPress, +}) => { + const [selectedDate, setSelectedDate] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [priorityFilter, setPriorityFilter] = useState(null); + const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('all'); + const [showCalendarModal, setShowCalendarModal] = useState(false); + const [calendarViewDate, setCalendarViewDate] = useState(dayjs().format('YYYY-MM-DD')); + const output = widget.toolOutput; + + if (!output) return null; + + // Determina il tipo di contenuto + const isTaskList = output.type === 'task_list'; + const isCategoryList = output.type === 'category_list'; + const isNoteList = output.type === 'note_list'; + + // Prepara i dati + const items = isTaskList ? output.tasks : isCategoryList ? output.categories : output.notes; + const title = isTaskList ? 'Task' : isCategoryList ? 'Categorie' : 'Note'; + + console.log('[VisualizationModal] Rendering', { + isTaskList, + itemsCount: items?.length, + outputType: output?.type, + widgetStatus: widget.status, + firstItem: items?.[0] + }); + + // Filtra task per data, ricerca, priorità e stato + const filteredItems = useMemo(() => { + if (!isTaskList || !output.tasks) return items; + + let filtered = output.tasks; + + // Filtra per data selezionata (controlla sia end_time che start_time) + if (selectedDate) { + filtered = filtered.filter((task: TaskListItem) => { + const item = task as any; + // Controlla tutti i possibili campi data + const endTime = item.endTime || item.end_time || item.endTimeFormatted; + const startTime = item.startTime || item.start_time || item.startTimeFormatted; + + // Se ha end_time, usa quello per il confronto + if (endTime) { + const taskDate = dayjs(endTime).format('YYYY-MM-DD'); + return taskDate === selectedDate; + } + // Altrimenti usa start_time se disponibile + if (startTime) { + const taskDate = dayjs(startTime).format('YYYY-MM-DD'); + return taskDate === selectedDate; + } + + return false; + }); + } + + // Filtra per ricerca + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter((task: TaskListItem) => + task.title.toLowerCase().includes(query) || + (task.category_name && task.category_name.toLowerCase().includes(query)) + ); + } + + // Filtra per priorità + if (priorityFilter) { + filtered = filtered.filter((task: TaskListItem) => + task.priority === priorityFilter + ); + } + + // Filtra per stato + if (statusFilter === 'pending') { + filtered = filtered.filter((task: TaskListItem) => { + const item = task as any; + const isCompleted = task.completed || item.status === 'Completato'; + return !isCompleted; + }); + } else if (statusFilter === 'completed') { + filtered = filtered.filter((task: TaskListItem) => { + const item = task as any; + const isCompleted = task.completed || item.status === 'Completato'; + return isCompleted; + }); + } + + return filtered; + }, [selectedDate, searchQuery, priorityFilter, statusFilter, isTaskList, output.tasks, items]); + + const displayItems = filteredItems || items; + + // Debug filtro + if (isTaskList && selectedDate) { + console.log('[VisualizationModal] Filtro data attivo:', { + selectedDate, + totalTasks: output.tasks?.length, + filteredTasks: filteredItems?.length, + firstFilteredTask: filteredItems?.[0] + }); + } + + // Ottieni colore priorità + const getPriorityColor = (priority?: string): string => { + const priorityColors: Record = { + 'Alta': '#000000', + 'Media': '#333333', + 'Bassa': '#666666', + }; + return priority ? (priorityColors[priority] || '#999999') : '#999999'; + }; + + // Formatta la data + const formatDate = (dateString?: string): string => { + if (!dateString) return ''; + try { + const date = new Date(dateString); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const isToday = date.toDateString() === today.toDateString(); + const isTomorrow = date.toDateString() === tomorrow.toDateString(); + + if (isToday) { + return `Oggi, ${date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`; + } else if (isTomorrow) { + return `Domani, ${date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`; + } else { + return date.toLocaleDateString('it-IT', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + } + } catch { + return dateString; + } + }; + + // Converti TaskListItem a Task per TaskCard + const convertToTask = (taskListItem: TaskListItem): TaskType => { + const item = taskListItem as any; + + // Determina start_time e end_time dai vari formati possibili + const startTime = item.startTime || item.start_time || item.startTimeFormatted; + const endTime = item.endTime || item.end_time || item.endTimeFormatted || taskListItem.end_time; + + return { + task_id: taskListItem.id, + id: taskListItem.id, + title: taskListItem.title, + description: item.description || '', + // Usa start_time se disponibile, altrimenti end_time, altrimenti data corrente + start_time: startTime || endTime || new Date().toISOString(), + // Usa end_time se disponibile, altrimenti stringa vuota + end_time: endTime || '', + category_id: item.category_id || item.categoryId || 0, + category_name: taskListItem.category_name || taskListItem.category || item.category || '', + priority: taskListItem.priority || item.priority || 'Media', + status: taskListItem.status || item.status || (taskListItem.completed ? 'Completato' : 'In sospeso'), + user_id: item.user_id || item.userId || 0, + is_recurring: item.is_recurring || item.isRecurring || false, + created_at: item.created_at || item.createdAt || new Date().toISOString(), + updated_at: item.updated_at || item.updatedAt || new Date().toISOString(), + }; + }; + + // Renderizza un singolo item + const renderItem = (item: any, index: number) => { + if (isTaskList) { + const taskListItem = item as TaskListItem; + const task = convertToTask(taskListItem); + + return ( + { + // Handle task completion + if (onItemPress) { + onItemPress({ ...taskListItem, completed: true }, 'task'); + } + }} + onTaskUncomplete={async (taskId: number) => { + // Handle task uncomplete + if (onItemPress) { + onItemPress({ ...taskListItem, completed: false }, 'task'); + } + }} + onTaskEdit={(taskId: number, updatedTask: TaskType) => { + // Handle task edit + if (onItemPress) { + onItemPress({ ...taskListItem, ...updatedTask }, 'task'); + } + }} + onTaskDelete={(taskId: number) => { + // Handle task deletion + if (onItemPress) { + onItemPress(taskListItem, 'task'); + } + }} + isOwned={true} + permissionLevel="READ_WRITE" + hideCheckbox={false} + /> + ); + } + + if (isCategoryList) { + // Usa imageUrl solo se è un URL valido (inizia con http/https/file://) + // Altrimenti lascia undefined per usare l'icona predefinita + const validImageUrl = item.imageUrl || item.icon; + const isValidUrl = validImageUrl && ( + validImageUrl.startsWith('http://') || + validImageUrl.startsWith('https://') || + validImageUrl.startsWith('file://') + ); + + return ( + { + console.log('[VisualizationModal] Category pressed:', item.name); + console.log('[VisualizationModal] onCategoryPress available:', !!onCategoryPress); + if (onCategoryPress) { + onCategoryPress(item); + } else { + console.warn('[VisualizationModal] onCategoryPress is not defined!'); + } + }} + > + + {isValidUrl ? ( + + ) : ( + + )} + + + + {item.name} + + {item.description && ( + + {item.description} + + )} + + + + + {item.taskCount || item.task_count || 0} task + + + {item.isShared && ( + + + Condiviso + + )} + + + + + ); + } + + if (isNoteList) { + return ( + onItemPress && onItemPress(item, 'note')} + > + + + + + + {item.title} + + {item.content && ( + + {item.content} + + )} + + + + ); + } + + return null; + }; + + return ( + + + {/* HEADER */} + + + + + {title} + + + + + {/* SEARCH BAR CON PULSANTE CALENDARIO (solo per task) */} + {isTaskList && ( + + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + {/* Pulsante Calendario */} + setShowCalendarModal(true)} + > + + + + )} + + {/* FILTRI (solo per task) */} + {isTaskList && ( + + + {/* Filtro Stato */} + setStatusFilter('all')} + > + + Tutti + + + + setStatusFilter('pending')} + > + + In corso + + + + setStatusFilter('completed')} + > + + Completati + + + + + + {/* Filtro Priorità */} + setPriorityFilter(priorityFilter === 'Alta' ? null : 'Alta')} + > + + Alta + + + + setPriorityFilter(priorityFilter === 'Media' ? null : 'Media')} + > + + Media + + + + setPriorityFilter(priorityFilter === 'Bassa' ? null : 'Bassa')} + > + + Bassa + + + + + )} + + {/* Indicatore data selezionata */} + {isTaskList && selectedDate && ( + + + Filtrato per: {dayjs(selectedDate).format('DD MMMM YYYY')} + + setSelectedDate(null)}> + + + + )} + + + {/* LISTA ITEMS */} + + {displayItems && displayItems.length > 0 ? ( + displayItems.map((item: any, index: number) => renderItem(item, index)) + ) : ( + + + + {selectedDate + ? 'Nessun elemento per questa data' + : 'Nessun elemento trovato'} + + + )} + + + + + {/* MODAL CALENDARIO */} + {isTaskList && ( + setShowCalendarModal(false)} + > + + {/* Header del modal calendario */} + + setShowCalendarModal(false)}> + + + Seleziona Data + + + + {/* Calendario */} + + { + const item = task as any; + // Includi task con end_time O start_time + const endTime = item.endTime || item.end_time || item.endTimeFormatted; + const startTime = item.startTime || item.start_time || item.startTimeFormatted; + return endTime || startTime; + }) + .map((task: TaskListItem) => convertToTask(task)) || []} + onSelectDate={(date) => { + if (date) { + setSelectedDate(date); + setShowCalendarModal(false); + } + }} + onPreviousMonth={() => { + const newDate = dayjs(calendarViewDate).subtract(1, 'month').format('YYYY-MM-DD'); + setCalendarViewDate(newDate); + }} + onNextMonth={() => { + const newDate = dayjs(calendarViewDate).add(1, 'month').format('YYYY-MM-DD'); + setCalendarViewDate(newDate); + }} + /> + + + {/* Pulsante per rimuovere filtro */} + {selectedDate && ( + + { + setSelectedDate(null); + setShowCalendarModal(false); + }} + > + Mostra tutti i task + + + )} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F9F9F9', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 16, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1.5, + borderBottomColor: '#E1E5E9', + }, + closeButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#000000', + }, + scrollView: { + flex: 1, + }, + // Search bar + searchContainer: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 8, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderBottomWidth: 1, + borderBottomColor: '#E1E5E9', + }, + searchBar: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#f8f8f8', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + gap: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: '#000000', + fontFamily: 'System', + }, + // Filtri + filtersContainer: { + backgroundColor: '#FFFFFF', + paddingVertical: 12, + marginBottom: 8, + borderBottomWidth: 1, + borderBottomColor: '#E1E5E9', + }, + filtersScrollContent: { + paddingHorizontal: 16, + gap: 8, + }, + filterChip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: '#f8f8f8', + borderWidth: 1.5, + borderColor: '#E1E5E9', + }, + filterChipActive: { + backgroundColor: '#000000', + borderColor: '#000000', + }, + filterChipPriorityHigh: { + backgroundColor: '#000000', + borderColor: '#000000', + }, + filterChipPriorityMedium: { + backgroundColor: '#333333', + borderColor: '#333333', + }, + filterChipPriorityLow: { + backgroundColor: '#666666', + borderColor: '#666666', + }, + filterChipText: { + fontSize: 14, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + filterChipTextActive: { + color: '#FFFFFF', + }, + filterDivider: { + width: 1, + height: 24, + backgroundColor: '#E5E5EA', + marginHorizontal: 4, + }, + calendarContainer: { + backgroundColor: '#FFFFFF', + marginBottom: 16, + paddingBottom: 16, + }, + clearDateButton: { + alignSelf: 'center', + paddingHorizontal: 20, + paddingVertical: 10, + marginTop: 12, + backgroundColor: '#E5F1FF', + borderRadius: 20, + }, + clearDateText: { + fontSize: 14, + fontWeight: '600', + color: '#007AFF', + }, + summaryContainer: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 20, + paddingVertical: 16, + marginBottom: 16, + borderRadius: 12, + marginHorizontal: 16, + }, + summaryText: { + fontSize: 14, + color: '#8E8E93', + marginVertical: 2, + }, + listContainer: { + paddingHorizontal: 16, + paddingBottom: 20, + }, + categoryListContainer: { + paddingHorizontal: 0, // Categories have their own margins + }, + categoryCardWrapper: { + marginBottom: 0, + }, + // Stili per categorie/note (mantieni per retrocompatibilità) + itemCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 3, + elevation: 1, + }, + itemIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + itemTextContainer: { + flex: 1, + }, + itemTitle: { + fontSize: 16, + fontWeight: '600', + color: '#000000', + marginBottom: 4, + }, + itemSubtitle: { + fontSize: 14, + color: '#8E8E93', + }, + categoryBadge: { + alignSelf: 'flex-start', + backgroundColor: '#E5F1FF', + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + marginTop: 6, + }, + categoryBadgeText: { + fontSize: 12, + fontWeight: '600', + color: '#007AFF', + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + emptyText: { + fontSize: 16, + color: '#8E8E93', + marginTop: 16, + }, + // Stili per il pulsante calendario + calendarButton: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: '#000000', + alignItems: 'center', + justifyContent: 'center', + }, + // Stili per l'indicatore data selezionata + selectedDateIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#E5F1FF', + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 16, + marginBottom: 8, + borderRadius: 12, + }, + selectedDateText: { + fontSize: 14, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + // Stili per il modal calendario + calendarModalContainer: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + calendarModalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E5EA', + }, + calendarModalTitle: { + fontSize: 18, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + calendarGridContainer: { + flex: 1, + padding: 20, + }, + calendarModalFooter: { + padding: 20, + borderTopWidth: 1, + borderTopColor: '#E5E5EA', + }, + clearFilterButton: { + backgroundColor: '#000000', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + clearFilterButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + fontFamily: 'System', + }, + // Stili per le category card personalizzate nella chat + categoryCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + marginBottom: 12, + marginHorizontal: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 3, + elevation: 1, + borderWidth: 1.5, + borderColor: '#E1E5E9', + }, + categoryIconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#E5F1FF', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + overflow: 'hidden', + }, + categoryImage: { + width: 48, + height: 48, + borderRadius: 24, + }, + categoryTextContainer: { + flex: 1, + }, + categoryTitle: { + fontSize: 17, + fontWeight: '600', + color: '#000000', + marginBottom: 4, + fontFamily: 'System', + }, + categoryDescription: { + fontSize: 14, + color: '#666666', + marginBottom: 6, + fontFamily: 'System', + }, + categoryMetaContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + categoryTaskCount: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + categoryTaskCountText: { + fontSize: 13, + color: '#666666', + fontFamily: 'System', + }, + categorySharedBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: '#E5F1FF', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 8, + }, + categorySharedText: { + fontSize: 12, + color: '#007AFF', + fontWeight: '600', + fontFamily: 'System', + }, +}); + +export default VisualizationModal; diff --git a/src/components/BotChat/widgets/VisualizationWidget.tsx b/src/components/BotChat/widgets/VisualizationWidget.tsx new file mode 100644 index 0000000..0feed5d --- /dev/null +++ b/src/components/BotChat/widgets/VisualizationWidget.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useRef } from 'react'; +import { StyleSheet, View, Text, TouchableOpacity, Animated, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ToolWidget, TaskListItem } from '../types'; +import TaskCard from '../../Task/TaskCard'; +import { Task as TaskType } from '../../../services/taskService'; + +interface VisualizationWidgetProps { + widget: ToolWidget; + onOpen: (widget: ToolWidget) => void; + onTaskPress?: (task: any) => void; + onCategoryPress?: (category: any) => void; +} + +/** + * Widget per tool di visualizzazione (show_tasks_to_user, show_categories_to_user, show_notes_to_user) + * Per task_list: mostra preview card-based (max 3) + * Per altri tipi: mostra card semplice con pulsante + */ +const VisualizationWidget: React.FC = ({ widget, onOpen, onTaskPress, onCategoryPress }) => { + const output = widget.toolOutput; + + // Se è in stato loading e non ha ancora output, mostra lo skeleton loader + if (widget.status === 'loading' && !output) { + return ; + } + + if (!output) return null; + + // Per task, categorie e note, usa il design semplice con bottone + let title = ''; + let itemCount = 0; + let icon: keyof typeof Ionicons.glyphMap = 'list'; + + if (output.type === 'task_list') { + title = 'Visualizza task'; + itemCount = output.tasks?.length || 0; + icon = 'calendar-outline'; + } else if (output.type === 'category_list') { + title = 'Visualizza categorie'; + itemCount = output.categories?.length || 0; + icon = 'folder-outline'; + } else if (output.type === 'note_list') { + title = 'Visualizza note'; + itemCount = output.notes?.length || 0; + icon = 'document-text-outline'; + } + + return ( + onOpen(widget)} + > + + + + + {title} + + {itemCount} {itemCount === 1 ? 'elemento' : 'elementi'} + + + + + ); +}; + +/** + * Loading widget con animazione per visualizzazione task/categorie/note + */ +const LoadingWidget: React.FC<{ widget: ToolWidget }> = ({ 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'; + + if (widget.toolName === 'show_tasks_to_user') { + loadingText = 'Recupero task dal server...'; + icon = 'calendar-outline'; + } else if (widget.toolName === 'show_categories_to_user') { + loadingText = 'Recupero categorie dal server...'; + icon = 'folder-outline'; + } else if (widget.toolName === 'show_notes_to_user') { + loadingText = 'Recupero note dal server...'; + icon = 'document-text-outline'; + } + + const shimmerTranslate = shimmerAnim.interpolate({ + inputRange: [-1, 1], + outputRange: [-200, 200], + }); + + return ( + + + + + + + {loadingText} + + + + + + + {/* Skeleton per task preview */} + {widget.toolName === 'show_tasks_to_user' && ( + + {[1, 2, 3].map((i) => ( + + + + + + + + + + + + ))} + + )} + + ); +}; + +/** + * Component for task list preview with card-based design + */ +const TaskListPreview: React.FC = ({ widget, onOpen, onTaskPress }) => { + const output = widget.toolOutput; + const tasks = output?.tasks || []; + + // Log ridotto - solo una volta all'inizio + console.log('[VisualizationWidget] Tasks received:', tasks.length); + + // Mostra max 3 task come preview + const MAX_PREVIEW_TASKS = 3; + const previewTasks = tasks.slice(0, MAX_PREVIEW_TASKS); + const hasMoreTasks = tasks.length > MAX_PREVIEW_TASKS; + + // Converti TaskListItem a Task per TaskCard + const convertToTask = (taskListItem: TaskListItem): TaskType => { + const item = taskListItem as any; + + // Log completo del task raw per il primo elemento + if (taskListItem.id === tasks[0]?.id) { + console.log('[VisualizationWidget] 🔍 RAW TASK DATA:', JSON.stringify(item, null, 2)); + } + + const converted = { + task_id: taskListItem.id, + id: taskListItem.id, + title: taskListItem.title, + description: item.description || '', + // Il server può inviare in diversi formati, prova tutti + start_time: item.startTime || item.start_time || item.startTimeFormatted || '', + end_time: item.endTime || item.end_time || item.endTimeFormatted || taskListItem.end_time || '', + category_id: item.category_id || item.categoryId || 0, + category_name: taskListItem.category_name || taskListItem.category || item.category || '', + priority: taskListItem.priority || item.priority || 'Media', + status: taskListItem.status || item.status || (taskListItem.completed ? 'Completato' : 'In sospeso'), + user_id: item.user_id || item.userId || 0, + is_recurring: item.is_recurring || item.isRecurring || false, + created_at: item.created_at || item.createdAt || new Date().toISOString(), + updated_at: item.updated_at || item.updatedAt || new Date().toISOString(), + }; + + // Log della conversione per il primo task + if (taskListItem.id === tasks[0]?.id) { + console.log('[VisualizationWidget] ✅ CONVERTED TASK:', { + description: converted.description, + start_time: converted.start_time, + end_time: converted.end_time, + category_name: converted.category_name + }); + } + + return converted; + }; + + // Empty state + if (tasks.length === 0) { + return ( + + + + Nessun task trovato + + + ); + } + + return ( + + + Task + {tasks.length > 0 && ( + + {tasks.length} + + )} + + + {/* Preview Task Cards usando il TaskCard classico */} + + {previewTasks.map((taskListItem: TaskListItem, index: number) => { + const task = convertToTask(taskListItem); + return ( + + ); + })} + + + {/* View All Button */} + {hasMoreTasks && ( + onOpen(widget)} + > + + Visualizza tutti ({tasks.length}) + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + // Stili per categorie/note (card semplice con bottone) + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + marginBottom: 8, + maxWidth: '85%', + borderWidth: 1.5, + borderColor: '#E1E5E9', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#f8f8f8', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: '500', + color: '#000000', + marginBottom: 2, + fontFamily: 'System', + letterSpacing: -0.3, + }, + subtitle: { + fontSize: 13, + color: '#666666', + fontFamily: 'System', + fontWeight: '300', + }, + + // Stili per task list preview (usa TaskCard classico) + container: { + backgroundColor: '#ffffff', + borderRadius: 16, + padding: 16, + marginVertical: 8, + marginHorizontal: 0, + borderColor: '#E1E5E9', + borderWidth: 1.5, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, + maxWidth: '85%', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + headerTitle: { + fontSize: 16, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + flex: 1, + }, + countBadge: { + backgroundColor: '#f8f8f8', + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + marginLeft: 8, + }, + countText: { + fontSize: 12, + fontWeight: '600', + color: '#666666', + fontFamily: 'System', + }, + taskCardsContainer: { + gap: 0, // TaskCard ha già i suoi margini + }, + viewAllButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#000000', + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + marginTop: 12, + gap: 6, + }, + viewAllText: { + fontSize: 14, + fontWeight: '600', + color: '#FFFFFF', + fontFamily: 'System', + }, + emptyMessageContainer: { + paddingVertical: 32, + paddingHorizontal: 20, + alignItems: 'center', + justifyContent: 'center', + }, + emptyMessage: { + fontSize: 14, + color: '#8E8E93', + textAlign: 'center', + fontFamily: 'System', + lineHeight: 20, + marginTop: 12, + }, + + // Loading widget styles + loadingContainer: { + backgroundColor: '#ffffff', + borderRadius: 16, + padding: 16, + marginVertical: 8, + marginHorizontal: 0, + borderColor: '#E1E5E9', + borderWidth: 1.5, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, + maxWidth: '85%', + }, + loadingHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + loadingIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#f8f8f8', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + loadingTextContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + loadingTitle: { + fontSize: 14, + fontWeight: '500', + color: '#666666', + fontFamily: 'System', + flex: 1, + }, + loadingDotsContainer: { + marginLeft: 8, + }, + skeletonTasksContainer: { + gap: 12, + }, + skeletonTaskCard: { + backgroundColor: '#f8f8f8', + borderRadius: 12, + padding: 12, + overflow: 'hidden', + position: 'relative', + }, + shimmerOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.4)', + width: 200, + }, + skeletonTaskContent: { + gap: 8, + }, + skeletonLine: { + height: 12, + backgroundColor: '#e0e0e0', + borderRadius: 6, + }, + skeletonTitle: { + width: '70%', + height: 14, + }, + skeletonSubtitle: { + width: '50%', + height: 12, + }, + skeletonTaskMeta: { + flexDirection: 'row', + gap: 8, + marginTop: 4, + }, + skeletonBadge: { + width: 60, + height: 20, + backgroundColor: '#e0e0e0', + borderRadius: 10, + }, +}); + +export default VisualizationWidget; diff --git a/src/components/BotChat/widgets/WidgetBubble.tsx b/src/components/BotChat/widgets/WidgetBubble.tsx new file mode 100644 index 0000000..fe1dd51 --- /dev/null +++ b/src/components/BotChat/widgets/WidgetBubble.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { View } from 'react-native'; +import { WidgetBubbleProps } from '../types'; +import CreationWidgetCard from './CreationWidgetCard'; +import VisualizationWidget from './VisualizationWidget'; + +/** + * Router component che decide quale widget renderizzare in base al tipo di tool + */ +const WidgetBubble: React.FC = ({ widget, onOpenVisualization, onOpenItemDetail, onTaskPress, onCategoryPress }) => { + // Lista dei tool di visualizzazione + const visualizationTools = ['show_tasks_to_user', 'show_categories_to_user', 'show_notes_to_user']; + + // Se è un tool di visualizzazione (anche in loading) + const isVisualizationTool = visualizationTools.includes(widget.toolName); + + // Se il widget ha un output di tipo "visualizzazione" O è un tool di visualizzazione in loading + if (isVisualizationTool || + (widget.toolOutput?.type && ['task_list', 'category_list', 'note_list'].includes(widget.toolOutput.type))) { + return ( + + ); + } + + // Handler per aprire dettaglio creazione + const handleCreationPress = () => { + if (!widget.toolOutput || widget.status !== 'success') return; + + const output = widget.toolOutput; + + // Parse toolArgs per ottenere i dati originali della richiesta + let toolArgsData: any = {}; + if (widget.toolArgs) { + try { + toolArgsData = JSON.parse(widget.toolArgs); + } catch (e) { + console.error('[WidgetBubble] Error parsing toolArgs:', e); + } + } + + // Determina tipo e item in base al tipo di output + if (output.type === 'task_created' && output.task) { + // Per i task, usa onTaskPress per aprire TaskEditModal + if (onTaskPress) { + // Converti al formato Task completo per TaskEditModal + // Usa toolArgs per i dati originali (title, description, etc.) + const taskForEdit = { + task_id: output.task.task_id, + id: output.task.task_id, + title: toolArgsData.title || output.task.title || '', + description: toolArgsData.description || output.task.description || '', + start_time: toolArgsData.start_time || output.task.start_time || new Date().toISOString(), + end_time: toolArgsData.end_time || output.task.end_time || '', + category_id: output.task.category_id || 0, + category_name: output.task.category_name || '', + priority: toolArgsData.priority || output.task.priority || 'Media', + status: output.task.status || 'In sospeso', + user_id: 0, + is_recurring: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + onTaskPress(taskForEdit); + } + } else if (output.type === 'category_created' && output.category) { + // Per le categorie, usa onCategoryPress per aprire il CategoryMenu + if (onCategoryPress) { + // Usa toolArgs per ottenere i dati originali della categoria (come per i task) + const categoryItem = { + ...output.category, + id: output.category.category_id, + // Usa toolArgsData per name e description, come facciamo per i task + name: toolArgsData.name || output.category.name || '', + description: toolArgsData.description || output.category.description || '', + // Assicurati che i campi necessari per CategoryMenu siano presenti + isOwned: output.category.isOwned !== undefined ? output.category.isOwned : true, + permissionLevel: output.category.permissionLevel || "READ_WRITE", + }; + console.log('[WidgetBubble] Category item to pass:', categoryItem); + console.log('[WidgetBubble] toolArgsData:', toolArgsData); + onCategoryPress(categoryItem); + } + } else if (output.type === 'note_created' && output.note && onOpenItemDetail) { + // Assicurati che la nota abbia tutti i campi necessari + const noteItem = { + ...output.note, + id: output.note.note_id, + }; + onOpenItemDetail(noteItem, 'note'); + } + }; + + // Altrimenti renderizza il widget di creazione (task_created, category_created, note_created) + return ; +}; + +export default WidgetBubble; diff --git a/src/components/Category/ManageCategoryShares.tsx b/src/components/Category/ManageCategoryShares.tsx index 04d668c..9d1007b 100644 --- a/src/components/Category/ManageCategoryShares.tsx +++ b/src/components/Category/ManageCategoryShares.tsx @@ -336,7 +336,7 @@ const styles = StyleSheet.create({ color: "#999", }, addButton: { - backgroundColor: "#007AFF", + backgroundColor: "#000000", marginHorizontal: 20, marginTop: 10, padding: 16, diff --git a/src/components/Category/ShareCategoryDialog.tsx b/src/components/Category/ShareCategoryDialog.tsx index 99720f9..2f33281 100644 --- a/src/components/Category/ShareCategoryDialog.tsx +++ b/src/components/Category/ShareCategoryDialog.tsx @@ -103,7 +103,7 @@ const ShareCategoryDialog: React.FC = ({ style={styles.overlay} > - Condividi "{categoryName}" + Condividi "{categoryName}" Email utente: @@ -239,7 +239,7 @@ const styles = StyleSheet.create({ height: 20, borderRadius: 10, borderWidth: 2, - borderColor: "#007AFF", + borderColor: "#000000", justifyContent: "center", alignItems: "center", marginRight: 10, @@ -248,7 +248,7 @@ const styles = StyleSheet.create({ width: 10, height: 10, borderRadius: 5, - backgroundColor: "#007AFF", + backgroundColor: "#000000", }, radioLabel: { fontSize: 14, @@ -285,7 +285,7 @@ const styles = StyleSheet.create({ fontWeight: "600", }, primaryButton: { - backgroundColor: "#007AFF", + backgroundColor: "#000000", }, primaryButtonText: { color: "#fff", diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 76b2d3b..95de432 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -32,6 +32,7 @@ import NotificationDebugScreen from "./screens/NotificationDebug"; import BugReportScreen from "./screens/BugReport"; //import StatisticsScreen from "./screens/Statistics"; import { NotFound as NotFoundScreen } from "./screens/NotFound"; +import CalendarWidgetDemoScreen from "./screens/CalendarWidgetDemo"; import eventEmitter, { emitScreenChange, EVENTS } from "../utils/eventEmitter"; import { useNotifications } from "../services/notificationService"; import AppInitializer from "../services/AppInitializer"; @@ -65,6 +66,7 @@ export type RootStackParamList = { GoogleCalendar: undefined; NotificationDebug: undefined; BugReport: undefined; + CalendarWidgetDemo: undefined; Statistics: undefined; Updates: undefined; NotFound: undefined; @@ -358,12 +360,10 @@ function AppStack() { // Determine initial route based on authentication and welcome carousel status const getInitialRoute = () => { // TEMPORARY: Force Welcome Screen for testing - return "WelcomeCarousel"; - // Original logic (uncomment after testing): - // if (isAuthenticated) return "HomeTabs"; - // if (!hasSeenWelcome) return "WelcomeCarousel"; - // return "Login"; + if (isAuthenticated) return "HomeTabs"; + if (!hasSeenWelcome) return "WelcomeCarousel"; + return "Login"; }; const initialRoute = getInitialRoute(); @@ -462,6 +462,11 @@ function AppStack() { component={BugReportScreen} options={{ title: t('navigation.screens.bugReport') }} /> + diff --git a/src/navigation/screens/BotChat.tsx b/src/navigation/screens/BotChat.tsx index 700d9da..217c65a 100644 --- a/src/navigation/screens/BotChat.tsx +++ b/src/navigation/screens/BotChat.tsx @@ -155,70 +155,113 @@ const BotChat: React.FC = () => { console.log('Messaggio vocale gestito direttamente dal hook:', audioUri); }, []); - // Handler per inviare messaggi + // Handler per inviare messaggi con streaming + widgets const handleSendMessage = useCallback(async (text: string) => { - // Creiamo il messaggio dell'utente + // 1. Creiamo il messaggio dell'utente const userMessage: Message = { id: Math.random().toString(), text, sender: USER, start_time: new Date(), }; - + // Aggiungiamo il messaggio dell'utente setMessages(prevMessages => [...prevMessages, userMessage]); - + + // 2. Creiamo messaggio bot temporaneo per streaming + const tempId = Math.random().toString(); + setMessages(prevMessages => [ + ...prevMessages, + { + id: tempId, + text: "", + sender: BOT, + start_time: new Date(), + isStreaming: true, + toolWidgets: [], + } + ]); + + // 3. Accumulo dati streaming + let accumulatedText = ""; + let currentWidgets: any[] = []; + + // 4. Callback per aggiornare UI durante streaming + const onStreamChunk = (chunk: string, isComplete: boolean, toolWidgets?: any[]) => { + if (chunk) { + accumulatedText += chunk; + } + + if (toolWidgets) { + currentWidgets = toolWidgets; + } + + // Aggiorna il messaggio bot con testo + widgets accumulati + setMessages(prevMessages => + prevMessages.map(msg => + msg.id === tempId + ? { + ...msg, + text: accumulatedText, + toolWidgets: currentWidgets, + isStreaming: !isComplete, + isComplete, + } + : msg + ) + ); + }; + try { - // Otteniamo gli ultimi messaggi per inviarli al server + // 5. Otteniamo gli ultimi messaggi per contesto const currentMessages = [...messages, userMessage]; - const lastMessages = currentMessages.slice(-5); - - // Aggiungiamo un messaggio temporaneo "Bot sta scrivendo..." - const tempId = Math.random().toString(); - setMessages(prevMessages => [ - ...prevMessages, - { - id: tempId, - text: "Sto pensando...", - sender: BOT, - start_time: new Date(), - } - ]); - - // Otteniamo la risposta dal server usando il modello selezionato e i messaggi precedenti - const botResponseText = await sendMessageToBot(text, modelType, lastMessages); - - // Formatta il messaggio per il supporto Markdown - const formattedBotResponse = formatMessage(botResponseText); - - // Rimuoviamo il messaggio temporaneo e aggiungiamo la risposta reale - setMessages(prevMessages => { - const filtered = prevMessages.filter(msg => msg.id !== tempId); - return [ - ...filtered, - { - id: Math.random().toString(), - text: formattedBotResponse, - sender: BOT, - start_time: new Date(), - modelType: modelType // Aggiungiamo l'informazione sul modello utilizzato - } - ]; - }); - + const lastMessages = currentMessages.slice(-6); // Ultimi 6 messaggi + + // 6. Invia richiesta con streaming callback + const result = await sendMessageToBot( + text, + modelType, + lastMessages, + onStreamChunk + ); + + // 7. Aggiornamento finale con dati completi + const formattedText = formatMessage(result.text); + + setMessages(prevMessages => + prevMessages.map(msg => + msg.id === tempId + ? { + ...msg, + text: formattedText, + toolWidgets: result.toolWidgets, + isStreaming: false, + isComplete: true, + modelType, + } + : msg + ) + ); + } catch (error) { - console.log("Errore durante la comunicazione con il bot:", error); - // In caso di errore, mostriamo un messaggio di errore - setMessages(prevMessages => [ - ...prevMessages, - { - id: Math.random().toString(), - text: "Mi dispiace, si è verificato un errore. Riprova più tardi.", - sender: BOT, - start_time: new Date(), - } - ]); - } }, [modelType, messages]); + console.error("Errore durante la comunicazione con il bot:", error); + + // In caso di errore, aggiorna messaggio con errore + setMessages(prevMessages => + prevMessages.map(msg => + msg.id === tempId + ? { + ...msg, + text: "Mi dispiace, si è verificato un errore. Riprova più tardi.", + isStreaming: false, + isComplete: true, + toolWidgets: [], + } + : msg + ) + ); + } + }, [modelType, messages]); const keyboardConfig = getKeyboardAvoidingViewConfig(); // Per iPad, usiamo un approccio ibrido: KeyboardAvoidingView con offset minimo diff --git a/src/navigation/screens/CalendarWidgetDemo.tsx b/src/navigation/screens/CalendarWidgetDemo.tsx new file mode 100644 index 0000000..75058cb --- /dev/null +++ b/src/navigation/screens/CalendarWidgetDemo.tsx @@ -0,0 +1,1482 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Dimensions, + Modal, + SafeAreaView, + StatusBar, + Platform, + Animated, + Easing, +} from 'react-native'; +import { Calendar } from 'react-native-calendars'; +import { Ionicons } from '@expo/vector-icons'; + +interface Task { + id: string; + title: string; + date: string; + completed: boolean; +} + +interface Message { + id: string; + text: string; + isBot: boolean; + timestamp: Date; + taskWidget?: { + taskId: string; + action: 'created' | 'moved' | 'updated'; + }; +} + +const { height } = Dimensions.get('window'); + +export default function CalendarWidgetDemo() { + const [mode, setMode] = useState<'text' | 'voice'>('text'); + const [calendarModalVisible, setCalendarModalVisible] = useState(false); + const [inputText, setInputText] = useState(''); + const [isRecording, setIsRecording] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [botModifiedDates, setBotModifiedDates] = useState(['2025-12-30', '2026-01-02']); + const [focusMode, setFocusMode] = useState(false); + const [isBotSpeaking, setIsBotSpeaking] = useState(false); + const [botThinking, setBotThinking] = useState(false); + const [showTaskAnimation, setShowTaskAnimation] = useState(false); + const [movingTaskId, setMovingTaskId] = useState(null); + + // Animated values + const focusModeScale = useRef(new Animated.Value(0)).current; + const focusModeOpacity = useRef(new Animated.Value(0)).current; + const botPulse = useRef(new Animated.Value(1)).current; + const taskMoveAnim = useRef(new Animated.Value(0)).current; + const taskCardAnim = useRef(new Animated.Value(0)).current; + const thinkingDots = useRef([ + new Animated.Value(0), + new Animated.Value(0), + new Animated.Value(0), + ]).current; + + // Demo tasks + const [tasks, setTasks] = useState([ + { id: '1', title: 'Riunione team', date: '2025-12-29', completed: false }, + { id: '2', title: 'Palestra', date: '2025-12-30', completed: false }, + { id: '3', title: 'Appuntamento medico', date: '2025-12-31', completed: false }, + { id: '4', title: 'Compleanno Maria', date: '2026-01-02', completed: false }, + ]); + + // Demo messages + const [messages] = useState([ + { + id: '1', + text: 'Ciao! Come posso aiutarti oggi?', + isBot: true, + timestamp: new Date('2025-12-29T10:00:00'), + }, + { + id: '2', + text: 'Vorrei organizzare i miei impegni della settimana', + isBot: false, + timestamp: new Date('2025-12-29T10:01:00'), + }, + { + id: '3', + text: 'Perfetto! Ho aperto il calendario per te. Puoi vedere tutti i tuoi impegni e spostarli. Cosa vuoi organizzare per primo?', + isBot: true, + timestamp: new Date('2025-12-29T10:01:30'), + }, + { + id: '4', + text: 'Devo spostare la riunione a domani', + isBot: false, + timestamp: new Date('2025-12-29T10:02:00'), + }, + { + id: '5', + text: 'Ho spostato la "Riunione team" a domani, 30 dicembre.', + isBot: true, + timestamp: new Date('2025-12-29T10:02:15'), + taskWidget: { + taskId: '1', + action: 'moved', + }, + }, + { + id: '6', + text: 'Grazie! Aggiungi anche cena con amici il 2 gennaio', + isBot: false, + timestamp: new Date('2025-12-29T10:03:00'), + }, + { + id: '7', + text: 'Perfetto! Ho aggiunto "Cena con amici" per il 2 gennaio.', + isBot: true, + timestamp: new Date('2025-12-29T10:03:15'), + taskWidget: { + taskId: '4', + action: 'created', + }, + }, + ]); + + // Calendar marked dates + const getMarkedDates = () => { + const marked: any = {}; + tasks.forEach((task) => { + const isBotModified = botModifiedDates.includes(task.date); + const isSelected = selectedTaskId === task.id; + + if (!marked[task.date]) { + marked[task.date] = { + dots: [], + selected: isSelected, + selectedColor: isSelected ? '#000000' : undefined, + }; + } + + // Aggiungi evidenziazione per date modificate dal bot + if (isBotModified) { + marked[task.date].marked = true; + marked[task.date].dotColor = '#000000'; + } + + marked[task.date].dots.push({ + key: task.id, + color: task.completed ? '#666666' : '#000000', + }); + }); + return marked; + }; + + const handleTaskWidgetPress = (taskId: string) => { + setSelectedTaskId(taskId); + setCalendarModalVisible(true); + }; + + // Animazione bot pulse quando parla + useEffect(() => { + let pulseAnimation: Animated.CompositeAnimation | null = null; + + if (isBotSpeaking) { + pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(botPulse, { + toValue: 1.15, + duration: 800, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(botPulse, { + toValue: 1, + duration: 800, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]) + ); + pulseAnimation.start(); + } else { + botPulse.stopAnimation(); + botPulse.setValue(1); + } + + return () => { + if (pulseAnimation) { + pulseAnimation.stop(); + } + }; + }, [isBotSpeaking]); + + // Animazione thinking dots + useEffect(() => { + let thinkingAnimations: Animated.CompositeAnimation[] = []; + + if (botThinking) { + const animations = thinkingDots.map((dot, index) => + Animated.loop( + Animated.sequence([ + Animated.delay(index * 200), + Animated.timing(dot, { + toValue: -8, + duration: 400, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(dot, { + toValue: 0, + duration: 400, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]) + ) + ); + + thinkingAnimations = animations; + Animated.parallel(animations).start(); + } else { + thinkingDots.forEach(dot => { + dot.stopAnimation(); + dot.setValue(0); + }); + } + + return () => { + thinkingAnimations.forEach(anim => anim.stop()); + }; + }, [botThinking]); + + // Animazione focus mode + useEffect(() => { + if (focusMode) { + Animated.parallel([ + Animated.spring(focusModeScale, { + toValue: 1, + friction: 8, + tension: 40, + useNativeDriver: true, + }), + Animated.timing(focusModeOpacity, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(focusModeScale, { + toValue: 0.8, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(focusModeOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } + }, [focusMode]); + + const toggleRecording = () => { + const wasRecording = isRecording; + setIsRecording(!isRecording); + + if (!wasRecording && focusMode) { + // Inizia sequenza animazioni solo se entriamo in recording mode in focus + // Bot pensa + setBotThinking(true); + + const thinkingTimeout = setTimeout(() => { + setBotThinking(false); + setIsBotSpeaking(true); + + // Dopo 1.5sec inizia animazione spostamento + const animationTimeout = setTimeout(() => { + setMovingTaskId('1'); + setShowTaskAnimation(true); + + // Animazione task card che appare + Animated.sequence([ + Animated.spring(taskCardAnim, { + toValue: 1, + friction: 8, + tension: 40, + useNativeDriver: true, + }), + Animated.delay(2000), + Animated.timing(taskCardAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(() => { + setShowTaskAnimation(false); + setMovingTaskId(null); + }); + + // Animazione movimento task effettivo + Animated.sequence([ + Animated.timing(taskMoveAnim, { + toValue: 1, + duration: 1500, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + useNativeDriver: true, + }), + Animated.timing(taskMoveAnim, { + toValue: 0, + duration: 0, + useNativeDriver: true, + }), + ]).start(); + }, 1500); + + // Ferma il bot che parla dopo 5 secondi TOTALI (2s thinking + 3s speaking) + const speakingTimeout = setTimeout(() => { + setIsBotSpeaking(false); + setIsRecording(false); + }, 3000); + + return () => { + clearTimeout(animationTimeout); + clearTimeout(speakingTimeout); + }; + }, 2000); + + return () => { + clearTimeout(thinkingTimeout); + }; + } else if (wasRecording) { + // Se stiamo fermando la registrazione, ferma tutto + setBotThinking(false); + setIsBotSpeaking(false); + setShowTaskAnimation(false); + setMovingTaskId(null); + } + }; + + const toggleTaskComplete = (taskId: string) => { + setTasks((prev) => + prev.map((t) => (t.id === taskId ? { ...t, completed: !t.completed } : t)) + ); + }; + + const handleAttachTask = () => { + // Apri modal per selezionare task da allegare + setCalendarModalVisible(true); + }; + + const toggleFocusMode = () => { + const wasFocusMode = focusMode; + setFocusMode(!focusMode); + + if (!wasFocusMode) { + // Entrando in focus mode - non fare nulla, aspetta che user prema rec + } else { + // Uscendo da focus mode - resetta tutto + setIsRecording(false); + setIsBotSpeaking(false); + setBotThinking(false); + setShowTaskAnimation(false); + setMovingTaskId(null); + + // Resetta anche le animazioni + taskCardAnim.setValue(0); + taskMoveAnim.setValue(0); + } + }; + + return ( + + + + {/* Header */} + + + Calendar Widget Demo + Concept di organizzazione collaborativa + + + setMode('text')} + > + + + setMode('voice')} + > + + + + + + {/* Calendar Modal */} + setCalendarModalVisible(false)} + > + + + + Calendario Organizzazione + setCalendarModalVisible(false)}> + + + + + { + console.log('Selected day:', day.dateString); + }} + /> + + {/* Task list */} + + + I tuoi impegni + {selectedTaskId && ( + setSelectedTaskId(null)}> + Deseleziona + + )} + + {tasks.map((task) => { + const isBotModified = botModifiedDates.includes(task.date); + const isSelected = selectedTaskId === task.id; + + return ( + setSelectedTaskId(task.id)} + activeOpacity={0.7} + > + + toggleTaskComplete(task.id)}> + + + + + + {task.title} + + {isBotModified && !isSelected && ( + + + Bot + + )} + {isBotModified && isSelected && ( + + + Bot + + )} + + + {task.date} + + + + + + + + ); + })} + + + + + + {/* Chat/Voice Interface */} + + {mode === 'text' ? ( + <> + {/* Text Chat Messages */} + + {messages.map((message) => ( + + + + {message.text} + + + {message.timestamp.toLocaleTimeString('it-IT', { + hour: '2-digit', + minute: '2-digit', + })} + + + + {/* Task Widget */} + {message.taskWidget && message.isBot && ( + handleTaskWidgetPress(message.taskWidget!.taskId)} + activeOpacity={0.7} + > + + + + + + + {tasks.find((t) => t.id === message.taskWidget!.taskId)?.title} + + + {tasks.find((t) => t.id === message.taskWidget!.taskId)?.date} + + + + + + + + Tocca per modificare nel calendario + + + + )} + + ))} + + + {/* Text Input */} + + + setCalendarModalVisible(true)} + > + + + + + + + + + + + + + ) : ( + <> + {/* Voice Chat Interface */} + {!focusMode ? ( + + + + {isRecording ? 'Ascolto in corso...' : 'Tocca per parlare'} + + + + {/* Waveform visualization */} + {isRecording && ( + + {[...Array(20)].map((_, i) => ( + + ))} + + )} + + {/* Voice messages transcription */} + + + Tu: + + "Vorrei organizzare i miei impegni della settimana" + + + + Bot: + + "Perfetto! Ho aperto il calendario per te. Puoi vedere tutti i + tuoi impegni. Cosa vuoi organizzare per primo?" + + + + + {/* Voice controls */} + + setCalendarModalVisible(true)} + > + + + + + + + + + + + ) : ( + /* Focus Mode - Calendar Full Screen */ + + { + console.log('Selected day:', day.dateString); + }} + /> + + {/* Task Animation Overlay */} + {showTaskAnimation && ( + + + + + Spostamento "Riunione team"... + + + + )} + + {/* Floating Bot Avatar */} + + + + {isBotSpeaking && ( + + + + + + )} + {botThinking && ( + + {thinkingDots.map((dot, index) => ( + + ))} + + )} + + + + + + + {/* Task List in Focus Mode */} + + + {tasks.map((task) => { + const isBotModified = botModifiedDates.includes(task.date); + const isMoving = movingTaskId === task.id; + + return ( + + + {task.title} + {task.date} + + {isBotModified && ( + + + + )} + {isMoving && ( + + + + )} + + ); + })} + + + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + header: { + paddingTop: 20, + paddingHorizontal: 15, + paddingBottom: 20, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + borderBottomWidth: 1, + borderBottomColor: '#e1e5e9', + }, + titleSection: { + flex: 1, + }, + headerTitle: { + fontSize: 30, + fontWeight: '200', + color: '#000000', + fontFamily: 'System', + letterSpacing: -1.5, + }, + headerSubtitle: { + fontSize: 14, + fontWeight: '300', + color: '#666666', + fontFamily: 'System', + marginTop: 4, + }, + modeToggle: { + flexDirection: 'row', + backgroundColor: '#f0f0f0', + borderRadius: 20, + padding: 4, + marginTop: 10, + }, + modeButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 16, + }, + modeButtonActive: { + backgroundColor: '#000000', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: '#ffffff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: height * 0.85, + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.25, + shadowRadius: 10, + elevation: 10, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 20, + borderBottomWidth: 1, + borderBottomColor: '#e1e5e9', + }, + modalTitle: { + fontSize: 24, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + calendar: { + borderBottomWidth: 1, + borderBottomColor: '#e1e5e9', + }, + taskListContainer: { + maxHeight: 250, + padding: 20, + }, + taskListHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 15, + }, + taskListTitle: { + fontSize: 18, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + clearSelectionText: { + fontSize: 14, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + taskItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#f8f9fa', + padding: 16, + borderRadius: 12, + marginBottom: 10, + borderWidth: 1, + borderColor: '#e1e5e9', + }, + taskItemSelected: { + backgroundColor: '#000000', + borderColor: '#000000', + }, + taskItemBotModified: { + borderColor: '#000000', + borderWidth: 1.5, + }, + taskInfo: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + taskTextContainer: { + marginLeft: 12, + flex: 1, + }, + taskTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + taskTitle: { + fontSize: 16, + fontWeight: '500', + color: '#000000', + fontFamily: 'System', + }, + taskTitleCompleted: { + textDecorationLine: 'line-through', + color: '#999999', + }, + taskTitleSelected: { + color: '#ffffff', + }, + botBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#f0f0f0', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + gap: 4, + }, + botBadgeSelected: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + botBadgeText: { + fontSize: 10, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + }, + botBadgeTextSelected: { + color: '#ffffff', + }, + taskDate: { + fontSize: 13, + color: '#666666', + marginTop: 4, + fontFamily: 'System', + fontWeight: '300', + }, + taskDateSelected: { + color: 'rgba(255, 255, 255, 0.7)', + }, + moveButton: { + padding: 8, + }, + chatContainer: { + flex: 1, + backgroundColor: '#ffffff', + }, + messagesContainer: { + flex: 1, + padding: 16, + }, + messageWrapper: { + marginBottom: 12, + }, + messageBubble: { + maxWidth: '80%', + padding: 12, + borderRadius: 16, + }, + botMessage: { + alignSelf: 'flex-start', + backgroundColor: '#f0f0f0', + }, + userMessage: { + alignSelf: 'flex-end', + backgroundColor: '#000000', + }, + messageText: { + fontSize: 16, + lineHeight: 22, + fontFamily: 'System', + }, + botMessageText: { + color: '#000000', + }, + userMessageText: { + color: '#ffffff', + }, + messageTime: { + fontSize: 11, + color: '#999999', + marginTop: 6, + textAlign: 'right', + fontFamily: 'System', + fontWeight: '300', + }, + messageTimeUser: { + color: 'rgba(255, 255, 255, 0.7)', + }, + inputSection: { + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 20, + paddingTop: 12, + backgroundColor: '#ffffff', + borderTopWidth: 1, + borderTopColor: '#e1e5e9', + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 30, + paddingHorizontal: 20, + paddingVertical: 10, + width: '100%', + borderWidth: 1.5, + borderColor: '#e1e5e9', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 3, + }, + calendarToggleButton: { + marginRight: 8, + padding: 4, + }, + attachButton: { + marginRight: 8, + padding: 4, + }, + textInput: { + flex: 1, + fontSize: 17, + color: '#000000', + fontFamily: 'System', + fontWeight: '400', + maxHeight: 100, + paddingVertical: 8, + }, + sendButton: { + marginLeft: 12, + padding: 8, + borderRadius: 20, + backgroundColor: '#f0f0f0', + }, + voiceContainer: { + flex: 1, + justifyContent: 'space-between', + backgroundColor: '#ffffff', + }, + voiceHeader: { + alignItems: 'center', + paddingVertical: 30, + }, + voiceStatus: { + fontSize: 20, + fontWeight: '300', + color: '#000000', + fontFamily: 'System', + letterSpacing: -0.5, + }, + waveformContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + height: 100, + gap: 4, + }, + waveformBar: { + width: 4, + backgroundColor: '#000000', + borderRadius: 2, + }, + transcriptionContainer: { + flex: 1, + padding: 16, + }, + transcriptionBubble: { + backgroundColor: '#f8f9fa', + padding: 16, + borderRadius: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: '#e1e5e9', + }, + botTranscription: { + backgroundColor: '#f0f0f0', + }, + transcriptionLabel: { + fontSize: 13, + fontWeight: '600', + color: '#666666', + marginBottom: 6, + fontFamily: 'System', + }, + transcriptionText: { + fontSize: 16, + color: '#000000', + lineHeight: 22, + fontFamily: 'System', + fontWeight: '300', + }, + voiceControls: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: 30, + gap: 24, + }, + voiceControlButton: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#f0f0f0', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: '#e1e5e9', + }, + recordButton: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#000000', + justifyContent: 'center', + alignItems: 'center', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + recordButtonActive: { + backgroundColor: '#666666', + }, + taskWidgetContainer: { + backgroundColor: '#ffffff', + borderRadius: 12, + borderWidth: 1.5, + borderColor: '#e1e5e9', + marginTop: 8, + marginLeft: 16, + maxWidth: '80%', + overflow: 'hidden', + }, + taskWidgetContent: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + }, + taskWidgetIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#f0f0f0', + justifyContent: 'center', + alignItems: 'center', + }, + taskWidgetInfo: { + flex: 1, + marginLeft: 12, + }, + taskWidgetTitle: { + fontSize: 15, + fontWeight: '500', + color: '#000000', + fontFamily: 'System', + marginBottom: 2, + }, + taskWidgetDate: { + fontSize: 13, + fontWeight: '300', + color: '#666666', + fontFamily: 'System', + }, + taskWidgetFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f8f9fa', + paddingVertical: 8, + gap: 6, + }, + taskWidgetFooterText: { + fontSize: 12, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + // Focus Mode Styles + focusModeContainer: { + flex: 1, + backgroundColor: '#ffffff', + }, + focusCalendar: { + height: height * 0.5, + }, + taskAnimationOverlay: { + position: 'absolute', + top: '40%', + left: 0, + right: 0, + alignItems: 'center', + zIndex: 10, + }, + animatedTaskCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#000000', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 25, + gap: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + animatedTaskText: { + fontSize: 16, + fontWeight: '500', + color: '#ffffff', + fontFamily: 'System', + }, + floatingBotContainer: { + position: 'absolute', + bottom: 30, + right: 20, + alignItems: 'center', + gap: 10, + }, + floatingBot: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: '#000000', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + floatingBotSpeaking: { + backgroundColor: '#333333', + }, + floatingBotThinking: { + backgroundColor: '#4A90E2', + }, + botSpeakingIndicator: { + position: 'absolute', + bottom: -8, + flexDirection: 'row', + gap: 4, + }, + speakingDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: '#ffffff', + }, + botThinkingIndicator: { + position: 'absolute', + top: -12, + flexDirection: 'row', + gap: 3, + }, + thinkingDot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: '#ffffff', + }, + exitFocusButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#f0f0f0', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: '#e1e5e9', + }, + focusTaskList: { + position: 'absolute', + bottom: 120, + left: 0, + right: 0, + paddingHorizontal: 16, + }, + focusTaskCard: { + backgroundColor: '#f8f9fa', + padding: 16, + borderRadius: 16, + marginRight: 12, + minWidth: 180, + borderWidth: 1, + borderColor: '#e1e5e9', + position: 'relative', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + focusTaskCardBotModified: { + borderColor: '#000000', + borderWidth: 2, + backgroundColor: '#fafafa', + }, + focusTaskCardContent: { + paddingRight: 24, + }, + focusTaskTitle: { + fontSize: 15, + fontWeight: '600', + color: '#000000', + fontFamily: 'System', + marginBottom: 6, + lineHeight: 20, + }, + focusTaskDate: { + fontSize: 13, + fontWeight: '400', + color: '#666666', + fontFamily: 'System', + }, + focusBotBadge: { + position: 'absolute', + top: 8, + right: 8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#000000', + justifyContent: 'center', + alignItems: 'center', + }, + movingTaskIndicator: { + position: 'absolute', + top: -8, + right: -8, + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#4A90E2', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#4A90E2', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 4, + elevation: 5, + }, +}); diff --git a/src/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 9e07807..ac10f7d 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -18,6 +18,7 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scrollview" 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, clearChatHistory, StreamingCallback } from "../../services/botservice"; import { STORAGE_KEYS } from "../../constants/authConstants"; import { TaskCacheService } from '../../services/TaskCacheService'; @@ -40,6 +41,7 @@ const HomeScreen = () => { const [suggestedCommandUsed, setSuggestedCommandUsed] = useState(false); const [screenHeight, setScreenHeight] = useState(Dimensions.get('window').height); const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isInputFocused, setIsInputFocused] = useState(false); // Tutorial context const tutorialContext = useTutorialContext(); @@ -66,6 +68,7 @@ const HomeScreen = () => { const messagesOpacity = useRef(new Animated.Value(1)).current; const inputBottomPosition = useRef(new Animated.Value(0)).current; const cursorOpacity = useRef(new Animated.Value(1)).current; + const micButtonAnim = useRef(new Animated.Value(1)).current; // Setup sync status listener useEffect(() => { const handleSyncStatus = (status: SyncStatus) => { @@ -175,6 +178,15 @@ const HomeScreen = () => { return () => subscription?.remove(); }, []); + // Effetto per animare il pulsante del microfono + useEffect(() => { + Animated.timing(micButtonAnim, { + toValue: isInputFocused ? 0 : 1, + duration: 200, + useNativeDriver: false, + }).start(); + }, [isInputFocused, micButtonAnim]); + // Effetto per gestire la visualizzazione della tastiera useEffect(() => { const keyboardDidShowListener = Keyboard.addListener( @@ -304,26 +316,42 @@ const HomeScreen = () => { try { // Callback per gestire lo streaming - const onStreamChunk: StreamingCallback = (chunk: string, isComplete: boolean) => { - if (typeof chunk !== 'string') { + const onStreamChunk: StreamingCallback = (chunk: string, isComplete: boolean, toolWidgets?: ToolWidget[]) => { + if (typeof chunk !== 'string' && chunk) { console.warn('[HOME] onStreamChunk received non-string chunk:', chunk); } - console.log('[HOME] onStreamChunk', { isComplete, chunkPreview: typeof chunk === 'string' ? chunk.slice(0, 40) : chunk }); + console.log('[HOME] onStreamChunk', { + isComplete, + chunkPreview: typeof chunk === 'string' ? chunk.slice(0, 40) : chunk, + widgetsCount: toolWidgets?.length || 0, + widgets: toolWidgets?.map(w => ({ toolName: w.toolName, status: w.status, type: w.toolOutput?.type })) + }); + if (isComplete) { - // Lo streaming è completato, rimuovi l'indicatore + // Lo streaming è completato, applica formatMessage al testo completo e aggiorna toolWidgets setMessages((prev) => prev.map((msg) => msg.id === botMessageId - ? { ...msg, isStreaming: false, isComplete: true } + ? { + ...msg, + text: formatMessage(msg.text), + isStreaming: false, + isComplete: true, + toolWidgets: toolWidgets || msg.toolWidgets + } : msg ) ); } else { - // Aggiungi il chunk al messaggio esistente + // Aggiungi il chunk al messaggio esistente e aggiorna toolWidgets se presenti setMessages((prev) => prev.map((msg) => msg.id === botMessageId - ? { ...msg, text: msg.text + chunk } + ? { + ...msg, + text: msg.text + chunk, + toolWidgets: toolWidgets || msg.toolWidgets + } : msg ) ); @@ -331,31 +359,13 @@ const HomeScreen = () => { }; // Invia il messaggio al bot con streaming - const botResponse = await sendMessageToBot( + await sendMessageToBot( trimmedMessage, "advanced", messages, onStreamChunk ); - // Formatta la risposta completa del bot per il supporto Markdown - const formattedBotResponse = formatMessage(botResponse); - console.log('[HOME] Final bot response length:', formattedBotResponse?.length ?? 0); - - // Aggiorna il messaggio con la risposta formattata finale - setMessages((prev) => - prev.map((msg) => - msg.id === botMessageId - ? { - ...msg, - text: formattedBotResponse, - isStreaming: false, - isComplete: true - } - : msg - ) - ); - } catch (error) { console.error("[HOME] Errore nell'invio del messaggio:", error); @@ -535,10 +545,37 @@ const HomeScreen = () => { + {/* TODO: Open calendar */}} + activeOpacity={0.7} + disabled={isLoading} + > + + + + + + + { returnKeyType="send" blurOnSubmit={true} editable={!isLoading} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} /> - - {/* Mostra il pulsante di invio se c'è del testo, altrimenti il microfono */} - {message.trim() ? ( - - - - ) : ( - - - - )} + + + @@ -654,10 +666,37 @@ const HomeScreen = () => { ]} > + {/* TODO: Open calendar */}} + activeOpacity={0.7} + disabled={isLoading} + > + + + + + + + { returnKeyType="send" blurOnSubmit={true} editable={!isLoading} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} /> - - {/* Mostra il pulsante di invio se c'è del testo, altrimenti il microfono */} - {message.trim() ? ( - - - - ) : ( - - - - )} + + + )} @@ -927,18 +941,20 @@ const styles = StyleSheet.create({ }, inputSection: { alignItems: "center", - paddingHorizontal: 40, - paddingBottom: 40, - paddingTop: 20, + paddingHorizontal: 16, + paddingBottom: 20, + paddingTop: 12, backgroundColor: "#ffffff", + borderTopWidth: 1, + borderTopColor: "#e1e5e9", }, inputContainer: { flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", backgroundColor: "#ffffff", borderRadius: 30, - paddingHorizontal: 24, - paddingVertical: 12, + paddingHorizontal: 20, + paddingVertical: 10, width: "100%", maxWidth: 420, borderWidth: 1.5, @@ -951,7 +967,14 @@ const styles = StyleSheet.create({ shadowOpacity: 0.08, shadowRadius: 12, elevation: 3, - minHeight: 50, + }, + calendarToggleButton: { + marginRight: 8, + padding: 4, + }, + attachButton: { + marginRight: 8, + padding: 4, }, textInput: { flex: 1, @@ -959,33 +982,14 @@ const styles = StyleSheet.create({ color: "#000000", fontFamily: "System", fontWeight: "400", - paddingVertical: 12, - textAlignVertical: "top", - minHeight: 26, - }, - voiceButton: { - marginLeft: 12, - padding: 8, - borderRadius: 20, - backgroundColor: "transparent", - alignSelf: "flex-end", - marginBottom: 8, - }, - voiceButtonDisabled: { - backgroundColor: "#f8f8f8", + maxHeight: 100, + paddingVertical: 8, }, sendButton: { marginLeft: 12, - padding: 10, + padding: 8, borderRadius: 20, backgroundColor: "#f0f0f0", - justifyContent: "center", - alignItems: "center", - alignSelf: "flex-end", - marginBottom: 8, - }, - sendButtonDisabled: { - backgroundColor: "#e8e8e8", }, suggestedCommandsContainer: { marginTop: 20, diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index 7f402d6..4f93d46 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -39,6 +39,10 @@ export default function Settings() { navigation.navigate('GoogleCalendar'); }; + const handleNavigateToCalendarWidgetDemo = () => { + navigation.navigate('CalendarWidgetDemo'); + }; + const testNotification = async () => { try { const response = await axiosInstance.post('api/notifications/test-timer-notification', { @@ -167,6 +171,17 @@ export default function Settings() { {t('settings.menu.testNotifications')} + + + + + Calendar Widget Demo + + + ); diff --git a/src/services/botservice.ts b/src/services/botservice.ts index 399bd9e..4c1ac52 100644 --- a/src/services/botservice.ts +++ b/src/services/botservice.ts @@ -1,11 +1,16 @@ import { getValidToken } from "./authService"; import { fetch } from 'expo/fetch'; +import { ToolWidget } from '../components/BotChat/types'; /** - * Callback per gestire chunk di testo in streaming + * Callback per gestire chunk di testo in streaming + widget tool */ -export type StreamingCallback = (chunk: string, isComplete: boolean) => void; +export type StreamingCallback = ( + chunk: string, + isComplete: boolean, + toolWidgets?: ToolWidget[] +) => void; /** * Invia un messaggio testuale al bot e riceve una risposta in streaming @@ -13,15 +18,15 @@ export type StreamingCallback = (chunk: string, isComplete: boolean) => void; * @param {string} userMessage - Il messaggio dell'utente da inviare al bot * @param {string} modelType - Il tipo di modello da utilizzare ('base' o 'advanced') * @param {Array} previousMessages - Gli ultimi messaggi scambiati tra utente e bot per il contesto - * @param {StreamingCallback} onStreamChunk - Callback per ricevere chunk in streaming (opzionale) - * @returns {Promise} - La risposta completa del bot + * @param {StreamingCallback} onStreamChunk - Callback per ricevere chunk in streaming + widgets (opzionale) + * @returns {Promise<{text: string, toolWidgets: ToolWidget[]}>} - La risposta completa del bot con widgets */ export async function sendMessageToBot( userMessage: string, modelType: "base" | "advanced" = "base", previousMessages: any[] = [], onStreamChunk?: StreamingCallback -): Promise { +): Promise<{text: string, toolWidgets: ToolWidget[]}> { try { // Verifica che l'utente sia autenticato const token = await getValidToken(); @@ -60,35 +65,156 @@ export async function sendMessageToBot( const decoder = new TextDecoder(); let fullMessage = ''; + // Mappa per tracciare i widget tool (usa item_index come chiave) + const toolWidgetsMap = new Map(); + // Mappa per tracciare tool_name per ogni item_index (workaround per tool_name: "unknown") + const toolNamesMap = new Map(); + try { while (true) { const { done, value } = await reader.read(); - + if (done) { break; } // Decodifica ogni chunk immediatamente const text = decoder.decode(value, { stream: true }); - + // Dividi il testo per linee per gestire più messaggi JSON const lines = text.split('\n').filter((line: string) => line.trim()); - + for (const line of lines) { if (line.startsWith('data: {')) { try { const jsonStr = line.replace('data: ', '').trim(); const parsed = JSON.parse(jsonStr); - if (parsed.type === 'content' && parsed.content) { - fullMessage += parsed.content; - console.log(parsed.content); // Log del contenuto in real-time + // EVENTO: tool_call - Crea widget in loading + if (parsed.type === 'tool_call') { + // Salva il tool_name per questo item_index + toolNamesMap.set(parsed.item_index, parsed.tool_name); + + const widgetId = `tool_${parsed.item_index}`; + const newWidget = { + id: widgetId, + toolName: parsed.tool_name, + status: 'loading' as const, + itemIndex: parsed.item_index, + toolArgs: parsed.tool_args, + }; + toolWidgetsMap.set(parsed.item_index, newWidget); + + // Notifica UI del nuovo widget loading + if (onStreamChunk) { + onStreamChunk('', false, Array.from(toolWidgetsMap.values())); + } + } + + // EVENTO: tool_output - Aggiorna widget con risultato + if (parsed.type === 'tool_output') { + // Usa item_index per trovare il widget (ignora tool_name che può essere "unknown") + let widget = toolWidgetsMap.get(parsed.item_index); + let widgetKey = parsed.item_index; // Traccia la chiave corretta del widget + + // WORKAROUND: Se non trova il widget per item_index, cerca per tool_name + // (il server a volte usa index diversi per tool_call e tool_output) + if (!widget && parsed.tool_name !== 'unknown') { + // Trova widget E la sua chiave originale + for (const [key, w] of toolWidgetsMap.entries()) { + if (w.toolName === parsed.tool_name && w.status === 'loading') { + widget = w; + widgetKey = key; // Usa la chiave originale del widget + break; + } + } + } + + // WORKAROUND 2: Se tool_name è "unknown", cerca l'ultimo widget in loading + if (!widget && parsed.tool_name === 'unknown') { + // Trova l'ultimo widget loading E la sua chiave + let lastLoadingKey: number | undefined; + for (const [key, w] of toolWidgetsMap.entries()) { + if (w.status === 'loading') { + widget = w; + lastLoadingKey = key; + } + } + if (lastLoadingKey !== undefined) { + widgetKey = lastLoadingKey; + } + } + + if (widget) { + try { + // Parsa l'output JSON del tool + let outputData = JSON.parse(parsed.output); + + // Se l'output è wrappato in {"type":"text","text":"..."}, estrailo + if (outputData.type === 'text' && outputData.text) { + outputData = JSON.parse(outputData.text); + } + + widget.status = outputData.success !== false ? 'success' : 'error'; + widget.toolOutput = outputData; + widget.errorMessage = outputData.success === false ? outputData.message : undefined; + + // Usa il tool_name salvato dal tool_call se quello nell'output è "unknown" + if (parsed.tool_name === 'unknown' && toolNamesMap.has(widgetKey)) { + widget.toolName = toolNamesMap.get(widgetKey)!; + } + } catch (e: any) { + widget.status = 'error'; + widget.errorMessage = 'Errore parsing output tool'; + console.error('[BOTSERVICE] Error parsing tool output:', e); + } + + // IMPORTANTE: Aggiorna il widget nella posizione ORIGINALE, non creare un duplicato + toolWidgetsMap.set(widgetKey, widget); + + // Notifica UI dell'aggiornamento widget + if (onStreamChunk) { + onStreamChunk('', false, Array.from(toolWidgetsMap.values())); + } + } else { + console.warn('[BOTSERVICE] Widget not found for index:', parsed.item_index); + } + } - // Chiama la callback se fornita + // EVENTO: content - Accumula testo messaggio + if (parsed.type === 'content' && (parsed.delta || parsed.content)) { + const textChunk = parsed.delta || parsed.content; + fullMessage += textChunk; + + // Chiama la callback con testo + widgets attuali if (onStreamChunk) { - onStreamChunk(parsed.content, false); + onStreamChunk(textChunk, false, Array.from(toolWidgetsMap.values())); } } + + // EVENTO: error - Marca widgets loading come error + if (parsed.type === 'error') { + console.error('Errore streaming:', parsed.message); + + // Marca tutti i widget loading come error + toolWidgetsMap.forEach((widget) => { + if (widget.status === 'loading') { + widget.status = 'error'; + widget.errorMessage = parsed.message || 'Errore sconosciuto'; + } + }); + + // Notifica UI dell'errore + if (onStreamChunk) { + onStreamChunk('', false, Array.from(toolWidgetsMap.values())); + } + } + + // EVENTO: done - Stream completato + if (parsed.type === 'done') { + // Stream completato, non serve loggare + } + } catch (e: any) { console.log("Errore parsing JSON per linea:", line); console.log("Errore:", e.message); @@ -102,29 +228,33 @@ export async function sendMessageToBot( // Notifica il completamento dello streaming if (onStreamChunk) { - onStreamChunk('', true); + onStreamChunk('', true, Array.from(toolWidgetsMap.values())); } - return fullMessage || "Nessuna risposta ricevuta dal bot."; + return { + text: fullMessage || "Nessuna risposta ricevuta dal bot.", + toolWidgets: Array.from(toolWidgetsMap.values()), + }; } catch (error: any) { console.error("❌ Errore nella comunicazione con il bot:", error); - + + let errorMessage = "Mi dispiace, si è verificato un errore. Riprova più tardi."; + // Gestisci errori specifici per fetch if (error.message?.includes('status: 401')) { - return "Sessione scaduta. Effettua nuovamente il login."; - } - - if (error.message?.includes('status: 429')) { - return "Troppe richieste. Riprova tra qualche secondo."; - } - - if (error.message?.includes('status: 5')) { - return "Il servizio è temporaneamente non disponibile. Riprova più tardi."; + errorMessage = "Sessione scaduta. Effettua nuovamente il login."; + } else if (error.message?.includes('status: 429')) { + errorMessage = "Troppe richieste. Riprova tra qualche secondo."; + } else if (error.message?.includes('status: 5')) { + errorMessage = "Il servizio è temporaneamente non disponibile. Riprova più tardi."; } - - // Errore generico - return "Mi dispiace, si è verificato un errore. Riprova più tardi."; + + // Ritorna messaggio di errore con widgets vuoti + return { + text: errorMessage, + toolWidgets: [], + }; } }