/** * inou Health Input Screen * Voice, Camera/OCR, and Text input for health data */ (function() { 'use strict'; // ======================================== // STATE // ======================================== const state = { mode: 'type', inputValue: '', isListening: false, recognition: null, transcript: '', interimTranscript: '', stream: null, facingMode: 'environment', capturedImage: null, ocrText: '', scanResult: null // { type: 'barcode'|'text'|'empty'|'error', format?, value } }; // ======================================== // DOM ELEMENTS // ======================================== const elements = {}; function initElements() { // Segment control elements.segmentBtns = document.querySelectorAll('.segment-btn'); elements.modePanels = document.querySelectorAll('.mode-panel'); // Text input elements.textInput = document.getElementById('text-input'); elements.charCount = document.getElementById('char-count'); // Voice input elements.micBtn = document.getElementById('mic-btn'); elements.micStatus = document.getElementById('mic-status'); elements.transcriptArea = document.getElementById('transcript-area'); elements.transcriptText = document.getElementById('transcript-text'); elements.transcriptInterim = document.getElementById('transcript-interim'); elements.voiceUnsupported = document.getElementById('voice-unsupported'); // Camera/Scan elements.cameraContainer = document.getElementById('camera-container'); elements.cameraStart = document.getElementById('camera-start'); elements.startCameraBtn = document.getElementById('start-camera-btn'); elements.cameraViewfinder = document.getElementById('camera-viewfinder'); elements.cameraVideo = document.getElementById('camera-video'); elements.switchCameraBtn = document.getElementById('switch-camera-btn'); elements.captureBtn = document.getElementById('capture-btn'); elements.closeCameraBtn = document.getElementById('close-camera-btn'); elements.photoPreview = document.getElementById('photo-preview'); elements.previewImg = document.getElementById('preview-img'); elements.retakeBtn = document.getElementById('retake-btn'); elements.processBtn = document.getElementById('process-btn'); elements.ocrResult = document.getElementById('ocr-result'); elements.ocrHeader = document.querySelector('.ocr-header'); elements.ocrText = document.getElementById('ocr-text'); elements.ocrEditBtn = document.getElementById('ocr-edit-btn'); elements.scanAnotherBtn = document.getElementById('scan-another-btn'); elements.useOcrBtn = document.getElementById('use-ocr-btn'); elements.ocrProcessing = document.getElementById('ocr-processing'); elements.cameraError = document.getElementById('camera-error'); elements.cameraErrorMsg = document.getElementById('camera-error-msg'); elements.retryCameraBtn = document.getElementById('retry-camera-btn'); elements.captureCanvas = document.getElementById('capture-canvas'); // Submit elements.submitBtn = document.getElementById('submit-btn'); } // ======================================== // MODE SWITCHING // ======================================== function switchMode(mode) { state.mode = mode; // Update segment buttons elements.segmentBtns.forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); // Update panels elements.modePanels.forEach(panel => { panel.classList.toggle('active', panel.dataset.panel === mode); }); // Stop voice if switching away if (mode !== 'speak' && state.isListening) { stopListening(); } // Stop camera if switching away if (mode !== 'scan' && state.stream) { stopCamera(); } updateSubmitButton(); } // ======================================== // TEXT INPUT // ======================================== function initTextInput() { elements.textInput.addEventListener('input', handleTextInput); elements.textInput.addEventListener('focus', handleTextFocus); } function handleTextInput(e) { const value = e.target.value; state.inputValue = value; elements.charCount.textContent = value.length; autoResize(e.target); updateSubmitButton(); } function handleTextFocus() { // Ensure mode is set to type when focusing text input if (state.mode !== 'type') { switchMode('type'); } } function autoResize(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.max(140, textarea.scrollHeight) + 'px'; } // ======================================== // VOICE INPUT (Web Speech API) // ======================================== function initVoiceInput() { // Check for support const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { elements.micBtn.style.display = 'none'; elements.micStatus.style.display = 'none'; elements.voiceUnsupported.style.display = 'block'; return; } // Initialize recognition state.recognition = new SpeechRecognition(); state.recognition.continuous = true; state.recognition.interimResults = true; state.recognition.lang = navigator.language || 'en-US'; // Event handlers state.recognition.onstart = () => { state.isListening = true; elements.micBtn.classList.add('listening'); elements.micStatus.textContent = 'Listening...'; }; state.recognition.onend = () => { state.isListening = false; elements.micBtn.classList.remove('listening'); elements.micStatus.textContent = 'Tap to start speaking'; // Finalize any interim transcript if (state.interimTranscript) { state.transcript += state.interimTranscript; state.interimTranscript = ''; updateTranscriptDisplay(); } updateSubmitButton(); }; state.recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); state.isListening = false; elements.micBtn.classList.remove('listening'); let errorMsg = 'Tap to try again'; if (event.error === 'not-allowed') { errorMsg = 'Microphone access denied. Check permissions.'; } else if (event.error === 'no-speech') { errorMsg = 'No speech detected. Tap to try again.'; } elements.micStatus.textContent = errorMsg; }; state.recognition.onresult = (event) => { let interim = ''; let final = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { final += transcript + ' '; } else { interim += transcript; } } if (final) { state.transcript += final; } state.interimTranscript = interim; updateTranscriptDisplay(); updateSubmitButton(); }; // Click handler elements.micBtn.addEventListener('click', toggleListening); } function toggleListening() { if (state.isListening) { stopListening(); } else { startListening(); } } function startListening() { if (!state.recognition) return; try { state.recognition.start(); } catch (e) { // Already started console.log('Recognition already started'); } } function stopListening() { if (!state.recognition) return; try { state.recognition.stop(); } catch (e) { console.log('Recognition already stopped'); } } function updateTranscriptDisplay() { const hasContent = state.transcript || state.interimTranscript; elements.transcriptArea.classList.toggle('has-content', hasContent); elements.transcriptText.textContent = state.transcript; elements.transcriptInterim.textContent = state.interimTranscript; } // ======================================== // CAMERA / OCR // ======================================== function initCamera() { elements.startCameraBtn.addEventListener('click', startCamera); elements.switchCameraBtn.addEventListener('click', switchCamera); elements.captureBtn.addEventListener('click', capturePhoto); elements.closeCameraBtn.addEventListener('click', closeCamera); elements.retakeBtn.addEventListener('click', retakePhoto); elements.processBtn.addEventListener('click', processOCR); elements.ocrEditBtn.addEventListener('click', toggleOCREdit); elements.scanAnotherBtn.addEventListener('click', scanAnother); elements.useOcrBtn.addEventListener('click', useOCRText); elements.retryCameraBtn.addEventListener('click', startCamera); } async function startCamera() { try { // Hide all states, show viewfinder hideAllCameraStates(); elements.cameraViewfinder.style.display = 'block'; // Get camera stream const constraints = { video: { facingMode: state.facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } } }; state.stream = await navigator.mediaDevices.getUserMedia(constraints); elements.cameraVideo.srcObject = state.stream; await elements.cameraVideo.play(); } catch (err) { console.error('Camera error:', err); showCameraError(getCameraErrorMessage(err)); } } function getCameraErrorMessage(err) { if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { return 'Camera access denied. Please allow camera permissions.'; } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { return 'No camera found on this device.'; } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { return 'Camera is in use by another application.'; } return 'Could not access camera. Please try again.'; } function showCameraError(message) { hideAllCameraStates(); elements.cameraErrorMsg.textContent = message; elements.cameraError.style.display = 'flex'; } function stopCamera() { if (state.stream) { state.stream.getTracks().forEach(track => track.stop()); state.stream = null; } elements.cameraVideo.srcObject = null; } function closeCamera() { stopCamera(); hideAllCameraStates(); elements.cameraStart.style.display = 'flex'; } async function switchCamera() { state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment'; stopCamera(); await startCamera(); } function capturePhoto() { const video = elements.cameraVideo; const canvas = elements.captureCanvas; // Set canvas size to video size canvas.width = video.videoWidth; canvas.height = video.videoHeight; // Draw current frame const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0); // Get data URL state.capturedImage = canvas.toDataURL('image/jpeg', 0.9); // Stop camera and show preview stopCamera(); hideAllCameraStates(); elements.previewImg.src = state.capturedImage; elements.photoPreview.style.display = 'block'; } function retakePhoto() { state.capturedImage = null; startCamera(); } // ======================================== // BARCODE DETECTION // ======================================== /** * Try to detect barcode using native BarcodeDetector API * Returns { found: boolean, format: string, value: string } or null */ async function detectBarcodeNative(imageSource) { if (!('BarcodeDetector' in window)) { return null; } try { const formats = await BarcodeDetector.getSupportedFormats(); const detector = new BarcodeDetector({ formats }); const barcodes = await detector.detect(imageSource); if (barcodes.length > 0) { const barcode = barcodes[0]; return { found: true, format: barcode.format, value: barcode.rawValue }; } } catch (err) { console.log('Native barcode detection failed:', err); } return { found: false }; } /** * Try to detect barcode via backend API */ async function detectBarcodeBackend(blob) { try { const formData = new FormData(); formData.append('image', blob, 'capture.jpg'); const response = await fetch('/api/barcode', { method: 'POST', body: formData }); if (!response.ok) { return { found: false }; } const result = await response.json(); if (result.found && result.value) { return { found: true, format: result.format || 'unknown', value: result.value }; } } catch (err) { console.log('Backend barcode detection failed:', err); } return { found: false }; } /** * Main processing function: Barcode → OCR → Nothing */ async function processImage() { if (!state.capturedImage) return; hideAllCameraStates(); elements.ocrProcessing.style.display = 'flex'; updateProcessingStatus('Scanning for barcode...'); try { const blob = dataURLtoBlob(state.capturedImage); // Create an image element for native barcode detection const img = new Image(); img.src = state.capturedImage; await new Promise(resolve => img.onload = resolve); // Step 1: Try native BarcodeDetector API (Chrome/Edge) let barcodeResult = await detectBarcodeNative(img); // Step 2: If native fails, try backend barcode detection if (!barcodeResult || !barcodeResult.found) { barcodeResult = await detectBarcodeBackend(blob); } // Step 3: If barcode found, show it if (barcodeResult && barcodeResult.found) { state.scanResult = { type: 'barcode', format: barcodeResult.format, value: barcodeResult.value }; showScanResult(); return; } // Step 4: No barcode, try OCR updateProcessingStatus('Extracting text...'); const formData = new FormData(); formData.append('image', blob, 'capture.jpg'); const response = await fetch('/api/ocr', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); if (result.text && result.text.trim()) { state.scanResult = { type: 'text', value: result.text }; showScanResult(); return; } } // Step 5: Nothing found state.scanResult = { type: 'empty', value: '' }; showScanResult(); } catch (err) { console.error('Image processing error:', err); state.scanResult = { type: 'error', value: 'Could not process image. Please try again.' }; showScanResult(); } } function updateProcessingStatus(message) { const statusEl = elements.ocrProcessing.querySelector('p'); if (statusEl) { statusEl.textContent = message; } } function showScanResult() { hideAllCameraStates(); const result = state.scanResult; if (result.type === 'barcode') { // Show barcode result elements.ocrHeader.innerHTML = `
`; elements.ocrText.textContent = result.value; elements.ocrText.classList.add('barcode-value'); } else if (result.type === 'text') { // Show OCR result elements.ocrHeader.innerHTML = `