From e79eda6a7d57a107c829fc6c6af32c9daab09e07 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 2 Oct 2025 11:52:06 +0000 Subject: [PATCH] Improve voice command UX and add desktop home navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice command improvements: - Auto-start listening when voice dialog opens (removes extra tap/click) - Added Activity option to unknown intent dialog - Users can now speak immediately after clicking the mic button Desktop navigation: - Added Home icon button in top bar/header for quick navigation to main page - Positioned between app title and user avatar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- maternal-app/maternal-app-backend/.env.update | 5 + .../maternal-app-backend/temp/test-audio.wav | Bin 0 -> 32044 bytes .../layouts/MobileNav/MobileNav.tsx | 8 + .../components/voice/VoiceFloatingButton.tsx | 6 + test-voice-e2e.js | 223 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 maternal-app/maternal-app-backend/.env.update create mode 100644 maternal-app/maternal-app-backend/temp/test-audio.wav create mode 100755 test-voice-e2e.js diff --git a/maternal-app/maternal-app-backend/.env.update b/maternal-app/maternal-app-backend/.env.update new file mode 100644 index 0000000..e16e999 --- /dev/null +++ b/maternal-app/maternal-app-backend/.env.update @@ -0,0 +1,5 @@ +# Update Azure OpenAI Chat credentials +AZURE_OPENAI_CHAT_ENDPOINT=https://footprints-open-ai.openai.azure.com +AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-5-mini +AZURE_OPENAI_CHAT_API_VERSION=2025-04-01-preview +AZURE_OPENAI_CHAT_API_KEY=a5f7e3e70a454a399f9216853b45e18b diff --git a/maternal-app/maternal-app-backend/temp/test-audio.wav b/maternal-app/maternal-app-backend/temp/test-audio.wav new file mode 100644 index 0000000000000000000000000000000000000000..3f1a79e7e4dab5becbac15d0e10360e6d77ed322 GIT binary patch literal 32044 zcmeIuAqs#%6a>((C)gtxJcnR#!D`Sfh|RWD@aV3=TfCVvOnl3nbALx{<2s#J=~6^A zwa-vT{o6=!>}CJ95FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAVA<3cmeTf B2iX7s literal 0 HcmV?d00001 diff --git a/maternal-web/components/layouts/MobileNav/MobileNav.tsx b/maternal-web/components/layouts/MobileNav/MobileNav.tsx index 06ca43c..6e9656f 100644 --- a/maternal-web/components/layouts/MobileNav/MobileNav.tsx +++ b/maternal-web/components/layouts/MobileNav/MobileNav.tsx @@ -63,6 +63,14 @@ export const MobileNav = () => { Maternal + router.push('/')} + sx={{ mr: 1 }} + > + + U diff --git a/maternal-web/components/voice/VoiceFloatingButton.tsx b/maternal-web/components/voice/VoiceFloatingButton.tsx index d7ac584..5e5aa66 100644 --- a/maternal-web/components/voice/VoiceFloatingButton.tsx +++ b/maternal-web/components/voice/VoiceFloatingButton.tsx @@ -121,6 +121,11 @@ export function VoiceFloatingButton() { setProcessingStatus(null); setIdentifiedActivity(''); setProcessedClassificationId(null); + + // Auto-start listening after dialog opens + setTimeout(() => { + startListening(); + }, 300); }; const handleClose = () => { @@ -484,6 +489,7 @@ export function VoiceFloatingButton() { Sleep Diaper Change Medicine + Activity diff --git a/test-voice-e2e.js b/test-voice-e2e.js new file mode 100755 index 0000000..29f72d4 --- /dev/null +++ b/test-voice-e2e.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +/** + * End-to-End Voice Command Test + * Tests the full voice flow: classify + create activity in database + */ + +const API_URL = process.env.API_URL || 'http://localhost:3020'; + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; + +// Test credentials +const TEST_USER = { + email: 'andrei@cloudz.ro', + password: 'Test1234!', +}; + +// Test commands +const commands = [ + 'Change wet diaper', + 'Baby ate 150ml formula', + 'Baby slept for 1 hour', + 'Alice ate 3 pcs of broccoli at 11:00 AM', +]; + +let accessToken = null; +let childId = null; + +async function login() { + console.log(`${colors.blue}Logging in...${colors.reset}`); + const response = await fetch(`${API_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }); + + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(`Login failed: ${data.message || 'Unknown error'}`); + } + + accessToken = data.data.accessToken; + console.log(`${colors.green}✓ Logged in successfully${colors.reset}\n`); +} + +async function getChild() { + console.log(`${colors.blue}Fetching children...${colors.reset}`); + const response = await fetch(`${API_URL}/api/v1/children`, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + const data = await response.json(); + if (!response.ok || !data.success || data.data.length === 0) { + throw new Error('No children found'); + } + + childId = data.data[0].id; + console.log(`${colors.green}✓ Found child: ${data.data[0].name} (${childId})${colors.reset}\n`); +} + +async function getActivitiesCount(type = null) { + let url = `${API_URL}/api/v1/activities?childId=${childId}&limit=1000`; + if (type) url += `&type=${type}`; + + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + const data = await response.json(); + return data.success ? data.data.length : 0; +} + +async function classifyAndCreateActivity(text) { + console.log(`${colors.yellow}Processing: "${text}"${colors.reset}`); + + // Step 1: Classify the command + const classifyResponse = await fetch(`${API_URL}/api/v1/voice/transcribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + text, + language: 'en', + childName: 'Alice', + }), + }); + + const classifyData = await classifyResponse.json(); + + if (!classifyResponse.ok || !classifyData.success) { + console.log(`${colors.red}✗ Classification failed${colors.reset}`); + console.log(JSON.stringify(classifyData, null, 2)); + return false; + } + + const { type, details, timestamp, confidence } = classifyData.classification; + + console.log(` Type: ${type} (confidence: ${confidence})`); + console.log(` Details: ${JSON.stringify(details)}`); + + if (type === 'unknown' || confidence < 0.3) { + console.log(`${colors.red}✗ Low confidence or unknown type${colors.reset}\n`); + return false; + } + + // Step 2: Create the activity + const activityData = { + type, + timestamp: timestamp || new Date().toISOString(), + data: details || {}, + notes: details?.notes || undefined, + }; + + const createResponse = await fetch(`${API_URL}/api/v1/activities`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + childId, + ...activityData, + }), + }); + + const createData = await createResponse.json(); + + if (!createResponse.ok || !createData.success) { + console.log(`${colors.red}✗ Failed to create activity${colors.reset}`); + console.log(JSON.stringify(createData, null, 2)); + return false; + } + + console.log(`${colors.green}✓ Activity created: ${createData.data.id}${colors.reset}\n`); + return true; +} + +async function runTests() { + console.log(`${colors.blue}========================================${colors.reset}`); + console.log(`${colors.blue}Voice E2E Test Suite${colors.reset}`); + console.log(`${colors.blue}========================================${colors.reset}\n`); + + try { + // Login and get child + await login(); + await getChild(); + + // Get initial counts + const initialCounts = { + total: await getActivitiesCount(), + diaper: await getActivitiesCount('diaper'), + feeding: await getActivitiesCount('feeding'), + sleep: await getActivitiesCount('sleep'), + }; + + console.log(`${colors.cyan}Initial activity counts:${colors.reset}`); + console.log(` Total: ${initialCounts.total}`); + console.log(` Diapers: ${initialCounts.diaper}`); + console.log(` Feedings: ${initialCounts.feeding}`); + console.log(` Sleep: ${initialCounts.sleep}\n`); + + // Run tests + let passed = 0; + let failed = 0; + + for (const command of commands) { + const result = await classifyAndCreateActivity(command); + if (result) { + passed++; + } else { + failed++; + } + } + + // Get final counts + const finalCounts = { + total: await getActivitiesCount(), + diaper: await getActivitiesCount('diaper'), + feeding: await getActivitiesCount('feeding'), + sleep: await getActivitiesCount('sleep'), + }; + + console.log(`${colors.cyan}Final activity counts:${colors.reset}`); + console.log(` Total: ${finalCounts.total} (+${finalCounts.total - initialCounts.total})`); + console.log(` Diapers: ${finalCounts.diaper} (+${finalCounts.diaper - initialCounts.diaper})`); + console.log(` Feedings: ${finalCounts.feeding} (+${finalCounts.feeding - initialCounts.feeding})`); + console.log(` Sleep: ${finalCounts.sleep} (+${finalCounts.sleep - initialCounts.sleep})\n`); + + // Summary + console.log(`${colors.blue}========================================${colors.reset}`); + console.log(`${colors.blue}Test Summary${colors.reset}`); + console.log(`${colors.blue}========================================${colors.reset}`); + console.log(`Total: ${commands.length}`); + console.log(`${colors.green}Passed: ${passed}${colors.reset}`); + console.log(`${colors.red}Failed: ${failed}${colors.reset}`); + console.log(''); + + if (failed === 0) { + console.log(`${colors.green}All tests passed! Activities saved to database. 🎉${colors.reset}`); + process.exit(0); + } else { + console.log(`${colors.red}Some tests failed. Check the output above.${colors.reset}`); + process.exit(1); + } + } catch (error) { + console.error(`${colors.red}Fatal error: ${error.message}${colors.reset}`); + console.error(error.stack); + process.exit(1); + } +} + +// Run tests +runTests();