diff --git a/src/components/BotChat/QuickVoiceAdd.tsx b/src/components/BotChat/QuickVoiceAdd.tsx new file mode 100644 index 0000000..6290039 --- /dev/null +++ b/src/components/BotChat/QuickVoiceAdd.tsx @@ -0,0 +1,408 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Animated, + Easing, + Platform, + Pressable, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, + useAudioRecorder, +} from 'expo-audio'; +import { useTranslation } from 'react-i18next'; +import { sendQuickVoiceAdd } from '../../services/quickVoiceAddService'; +import { colors } from '../../theme/tokens'; + +type QuickVoiceAddState = 'idle' | 'recording' | 'sending' | 'success'; + +interface QuickVoiceAddProps { + model: 'base' | 'advanced'; + disabled?: boolean; + showCompactIcon?: boolean; + variant?: 'pill' | 'fab'; + autoStart?: boolean; + containerStyle?: StyleProp; + onStateChange?: (state: QuickVoiceAddState) => void; + onSuccess?: () => void; + onCancel?: () => void; +} + +const BAR_COUNT = 18; + +const QuickVoiceAdd: React.FC = ({ + model, + disabled = false, + showCompactIcon = true, + variant = 'pill', + autoStart = false, + containerStyle, + onStateChange, + onSuccess, + onCancel, +}) => { + const { t } = useTranslation(); + const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + const [state, setState] = useState('idle'); + const expandAnim = useRef(new Animated.Value(0)).current; + const bars = useRef(Array.from({ length: BAR_COUNT }, () => new Animated.Value(0.25))).current; + const recordingStartRef = useRef(0); + const resetTimerRef = useRef | null>(null); + const isRecordingRef = useRef(false); + const autoStartedRef = useRef(false); + + const isExpanded = state !== 'idle'; + const isFab = variant === 'fab'; + + useEffect(() => { + onStateChange?.(state); + }, [onStateChange, state]); + + useEffect(() => { + Animated.spring(expandAnim, { + toValue: isExpanded ? 1 : 0, + damping: 18, + stiffness: 140, + mass: 0.9, + useNativeDriver: false, + }).start(); + }, [expandAnim, isExpanded]); + + useEffect(() => { + if (state !== 'recording') { + bars.forEach((bar) => bar.stopAnimation(() => bar.setValue(0.25))); + return; + } + + const loops = bars.map((bar, index) => { + const duration = 520 + (index % 5) * 90; + const delay = index * 38; + const loop = Animated.loop( + Animated.sequence([ + Animated.delay(delay), + Animated.timing(bar, { + toValue: 1, + duration, + easing: Easing.inOut(Easing.sin), + useNativeDriver: false, + }), + Animated.timing(bar, { + toValue: 0.18, + duration: duration + 120, + easing: Easing.inOut(Easing.sin), + useNativeDriver: false, + }), + ]) + ); + loop.start(); + return loop; + }); + + return () => loops.forEach((loop) => loop.stop()); + }, [bars, state]); + + useEffect(() => { + return () => { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + if (isRecordingRef.current) { + recorder.stop().catch((error) => { + console.warn('[QuickVoiceAdd] stop error:', error); + }); + } + }; + }, [recorder]); + + const startRecording = useCallback(async () => { + if (disabled || state !== 'idle') return; + + try { + const permission = await requestRecordingPermissionsAsync(); + if (!permission.granted) { + Alert.alert('Microfono non disponibile', 'Abilita il microfono per usare Quick add.'); + return; + } + + await setAudioModeAsync({ + allowsRecording: true, + playsInSilentMode: true, + }); + await recorder.prepareToRecordAsync(RecordingPresets.HIGH_QUALITY); + recorder.record(); + isRecordingRef.current = true; + recordingStartRef.current = Date.now(); + setState('recording'); + } catch (error) { + console.error('[QuickVoiceAdd] startRecording error:', error); + Alert.alert('Errore registrazione', 'Non sono riuscito ad avviare il microfono.'); + setState('idle'); + } + }, [disabled, recorder, state]); + + useEffect(() => { + if (!autoStart) { + autoStartedRef.current = false; + return; + } + + if (autoStartedRef.current || disabled || state !== 'idle') return; + autoStartedRef.current = true; + + const timer = setTimeout(() => { + startRecording(); + }, 120); + + return () => clearTimeout(timer); + }, [autoStart, disabled, startRecording, state]); + + const sendRecording = async () => { + if (state !== 'recording') return; + + try { + const elapsedMs = Date.now() - recordingStartRef.current; + if (elapsedMs < 450) { + await new Promise((resolve) => setTimeout(resolve, 450 - elapsedMs)); + } + + await recorder.stop(); + isRecordingRef.current = false; + const uri = recorder.uri; + if (!uri) { + throw new Error('File audio non disponibile'); + } + + setState('sending'); + const result = await sendQuickVoiceAdd(uri, model); + if (!result.received && !result.recivied) { + throw new Error(result.error || 'Audio non ricevuto dal server'); + } + + setState('success'); + onSuccess?.(); + resetTimerRef.current = setTimeout(() => { + setState('idle'); + }, 1050); + } catch (error: any) { + console.error('[QuickVoiceAdd] sendRecording error:', error); + Alert.alert('Invio non riuscito', error?.message || 'Riprova tra poco.'); + setState('idle'); + isRecordingRef.current = false; + } finally { + setAudioModeAsync({ allowsRecording: false }).catch(() => undefined); + } + }; + + const cancelRecording = async () => { + if (state !== 'recording') return; + try { + await recorder.stop(); + } catch { + // Ignore stop errors while cancelling. + } finally { + isRecordingRef.current = false; + setAudioModeAsync({ allowsRecording: false }).catch(() => undefined); + setState('idle'); + onCancel?.(); + } + }; + + const width = expandAnim.interpolate({ + inputRange: [0, 1], + outputRange: [isFab ? 56 : 138, 292], + }); + const height = expandAnim.interpolate({ + inputRange: [0, 1], + outputRange: [isFab ? 56 : 44, 64], + }); + const borderRadius = expandAnim.interpolate({ + inputRange: [0, 1], + outputRange: [isFab ? 28 : 22, 32], + }); + const backgroundColor = expandAnim.interpolate({ + inputRange: [0, 1], + outputRange: [isFab ? '#000000' : '#FFFFFF', '#FFFFFF'], + }); + const borderColor = expandAnim.interpolate({ + inputRange: [0, 1], + outputRange: [isFab ? '#000000' : colors.border, colors.border], + }); + const compactOpacity = expandAnim.interpolate({ + inputRange: [0, 0.45], + outputRange: [1, 0], + extrapolate: 'clamp', + }); + const expandedOpacity = expandAnim.interpolate({ + inputRange: [0.35, 1], + outputRange: [0, 1], + extrapolate: 'clamp', + }); + + return ( + + + + {isFab ? ( + + ) : ( + <> + {showCompactIcon && ( + + )} + + {t('home.quickAdd.label')} + + + )} + + + + + {state === 'success' ? ( + + + + {t('home.quickAdd.sent')} + + + ) : ( + <> + + + + + + {bars.map((bar, index) => { + const barHeight = bar.interpolate({ + inputRange: [0, 1], + outputRange: [8 + (index % 3) * 2, 34 - (index % 4) * 3], + }); + return ( + + ); + })} + + + + {state === 'sending' ? ( + + ) : ( + + )} + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + shell: { + marginTop: 28, + borderWidth: 1, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.08, + shadowRadius: 18, + elevation: 4, + overflow: 'hidden', + }, + compactContent: { + flex: 1, + gap: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + fabCompactContent: { + paddingHorizontal: 0, + }, + compactLabel: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + letterSpacing: 0, + }, + disabledText: { + color: colors.textTertiary, + }, + expandedContent: { + ...StyleSheet.absoluteFillObject, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + gap: 10, + }, + cancelButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, + waveform: { + flex: 1, + height: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Platform.OS === 'ios' ? 4 : 3, + }, + waveBar: { + width: 3, + borderRadius: 2, + backgroundColor: colors.textPrimary, + }, + submitButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#000000', + alignItems: 'center', + justifyContent: 'center', + }, + submitButtonDisabled: { + opacity: 0.78, + }, + successContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + successText: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700', + letterSpacing: 0, + }, +}); + +export default QuickVoiceAdd; diff --git a/src/components/Calendar/CalendarView.tsx b/src/components/Calendar/CalendarView.tsx index dd0150b..50c1464 100644 --- a/src/components/Calendar/CalendarView.tsx +++ b/src/components/Calendar/CalendarView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { View, ScrollView, StyleSheet, Alert, ActivityIndicator, Dimensions } from 'react-native'; +import { View, ScrollView, StyleSheet, Alert, ActivityIndicator, Modal, Pressable, TouchableOpacity } from 'react-native'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { Task as TaskType, getAllTasks, addTask, deleteTask, updateTask, completeTask, disCompleteTask } from '../../services/taskService'; @@ -12,7 +12,7 @@ import { useFocusEffect } from '@react-navigation/native'; import CalendarGrid from './CalendarGrid'; import Task from '../Task/Task'; import AddTask from '../Task/AddTask'; -import AddTaskButton from '../Task/AddTaskButton'; +import QuickVoiceAdd from '../BotChat/QuickVoiceAdd'; import { addTaskToList } from '../TaskList/types'; import { LoadingState, EmptyState, StatusChip, AppText } from '../UI/foundation'; @@ -26,12 +26,10 @@ const CalendarView: React.FC = () => { const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); const [tasks, setTasks] = useState([]); const [showAddTask, setShowAddTask] = useState(false); + const [voiceAddModalVisible, setVoiceAddModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(true); const [syncStatus, setSyncStatus] = useState(null); - // Screen dimensions - const screenWidth = Dimensions.get('window').width; - // Servizi const cacheService = useRef(TaskCacheService.getInstance()).current; const syncManager = useRef(SyncManager.getInstance()).current; @@ -355,6 +353,65 @@ const CalendarView: React.FC = () => { setShowAddTask(true); }; + const handleVoiceAddSuccess = () => { + fetchTasks(); + setTimeout(() => { + setVoiceAddModalVisible(false); + }, 1150); + }; + + const renderHeaderActions = () => ( + + + + + + setVoiceAddModalVisible(true)} + activeOpacity={0.82} + accessibilityRole="button" + accessibilityLabel={t("tasks.accessibility.addTaskLabel")} + accessibilityHint={t("tasks.accessibility.addTaskHint")} + > + + + + ); + + const renderVoiceAddModal = () => ( + setVoiceAddModalVisible(false)} + > + + setVoiceAddModalVisible(false)} + /> + + setVoiceAddModalVisible(false)} + containerStyle={styles.voiceAddFab} + /> + + + + ); + // Gestisce la chiusura del form const handleCloseAddTask = () => { setShowAddTask(false); @@ -421,7 +478,7 @@ const CalendarView: React.FC = () => { {t('calendar.commitmentsOf')} {dayjs(selectedDate).format('DD')} {t(`calendar.months.${MONTH_KEYS[dayjs(selectedDate).month()]}`)} {dayjs(selectedDate).year()} - + {renderHeaderActions()} {/* Componente di caricamento */} @@ -436,6 +493,8 @@ const CalendarView: React.FC = () => { categoryName={t('calendar.defaultCategory')} initialDate={selectedDate} /> + + {renderVoiceAddModal()} ); } @@ -475,7 +534,7 @@ const CalendarView: React.FC = () => { )} - + {renderHeaderActions()} @@ -499,6 +558,8 @@ const CalendarView: React.FC = () => { )} + {renderVoiceAddModal()} + {/* Componente AddTask con selezione categorie abilitata */} = ({ onClose }) => { const [searchVisible, setSearchVisible] = useState(false); const [miniCalendarVisible, setMiniCalendarVisible] = useState(false); const [addTaskVisible, setAddTaskVisible] = useState(false); + const [voiceAddActive, setVoiceAddActive] = useState(false); const [selectedDateForTask, setSelectedDateForTask] = useState(null); const cacheService = useRef(TaskCacheService.getInstance()).current; @@ -298,7 +299,7 @@ const Calendar20View: React.FC = ({ onClose }) => { } setAddTaskVisible(false); setSelectedDateForTask(null); - }, [currentDate, selectedDateForTask]); + }, [currentDate, selectedDateForTask, t]); const handleCategoryToggle = useCallback((categoryName: string) => { setEnabledCategories(prev => { @@ -370,11 +371,15 @@ const Calendar20View: React.FC = ({ onClose }) => { {renderView()} + {voiceAddActive && } + { setSelectedDateForTask(null); setAddTaskVisible(true); }} + onVoiceStateChange={setVoiceAddActive} + onVoiceSuccess={fetchTasks} /> void; + onVoiceStateChange?: (isActive: boolean) => void; + onVoiceSuccess?: () => void; } -const FABMenu: React.FC = ({ onNewTask }) => { +const FABMenu: React.FC = ({ onNewTask, onVoiceStateChange, onVoiceSuccess }) => { return ( - - - + + + + + + onVoiceStateChange?.(state !== 'idle')} + containerStyle={styles.voiceFab} + /> + ); }; const styles = StyleSheet.create({ - fab: { + dock: { position: 'absolute', bottom: 20, right: 20, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + zIndex: 220, + }, + voiceFab: { + marginTop: 0, + }, + manualFab: { width: 56, height: 56, borderRadius: 28, - backgroundColor: '#000000', + backgroundColor: '#ffffff', alignItems: 'center', justifyContent: 'center', + borderWidth: 1, + borderColor: '#e5e5ea', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.08, shadowRadius: 12, elevation: 3, - zIndex: 100, }, }); diff --git a/src/components/TaskList/TaskListContainer.tsx b/src/components/TaskList/TaskListContainer.tsx index 005eae3..64e3458 100644 --- a/src/components/TaskList/TaskListContainer.tsx +++ b/src/components/TaskList/TaskListContainer.tsx @@ -2,15 +2,16 @@ import React, { useState, useEffect, useMemo, useCallback, useLayoutEffect } fro import { View, ScrollView, Alert, TouchableOpacity } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { styles } from './styles'; import { Task as TaskType, globalTasksRef } from './types'; import eventEmitter, { EVENTS } from '../../utils/eventEmitter'; import { ActiveFilters } from './ActiveFilters'; import { FilterModal } from './FilterModal'; -import { AddTaskButton } from './AddTaskButton'; import { filterTasksByDay } from './TaskUtils'; import AddTask from '../Task/AddTask'; +import QuickVoiceAdd from '../BotChat/QuickVoiceAdd'; import { recurringTaskService, RecurringTask, CreateRecurringTaskPayload } from '../../services/recurringTaskService'; import { LoadingState, EmptyState } from '../UI/foundation'; import { CompletedTasksButton } from './CompletedTasksButton'; @@ -41,6 +42,7 @@ export const TaskListContainer = ({ taskService }: TaskListContainerProps) => { const { t } = useTranslation(); + const insets = useSafeAreaInsets(); const [tasks, setTasks] = useState([]); const [filtroImportanza, setFiltroImportanza] = useState("Tutte"); const [filtroScadenza, setFiltroScadenza] = useState("Tutte"); @@ -652,7 +654,29 @@ export const TaskListContainer = ({ onDeleteAll={handleDeleteAllCompleted} /> - + + + + + + + { )} + + )} @@ -1203,6 +1211,9 @@ const styles = StyleSheet.create({ marginTop: 20, alignItems: "center", }, + quickAddContainer: { + marginTop: 40, + }, suggestedCommandButton: { backgroundColor: "#f8f9fa", paddingHorizontal: 20, diff --git a/src/navigation/screens/Notes.tsx b/src/navigation/screens/Notes.tsx index d8168a0..d080e20 100644 --- a/src/navigation/screens/Notes.tsx +++ b/src/navigation/screens/Notes.tsx @@ -69,7 +69,7 @@ const NotesContent: React.FC = () => { export default function Notes() { return ( - + diff --git a/src/services/quickVoiceAddService.ts b/src/services/quickVoiceAddService.ts new file mode 100644 index 0000000..0e92989 --- /dev/null +++ b/src/services/quickVoiceAddService.ts @@ -0,0 +1,72 @@ +import { DEFAULT_BASE_URL } from '../constants/authConstants'; +import { getValidToken } from './authService'; + +export interface QuickVoiceAddResponse { + received: boolean; + recivied?: boolean; + processing?: boolean; + model?: 'base' | 'advanced'; + remaining?: { + voice_daily?: number | string; + voice_monthly?: number | string; + }; + rate_limit?: unknown; + error?: string; +} + +function getContentType(uri: string): string { + const lower = uri.toLowerCase(); + if (lower.endsWith('.m4a') || lower.endsWith('.mp4')) return 'audio/mp4'; + if (lower.endsWith('.mp3') || lower.endsWith('.mpeg')) return 'audio/mpeg'; + if (lower.endsWith('.wav')) return 'audio/wav'; + if (lower.endsWith('.webm')) return 'audio/webm'; + if (lower.endsWith('.ogg') || lower.endsWith('.oga')) return 'audio/ogg'; + return 'audio/mp4'; +} + +function getFileName(uri: string): string { + const name = uri.split('/').pop(); + return name && name.includes('.') ? name : `quick-voice-add-${Date.now()}.m4a`; +} + +export async function sendQuickVoiceAdd( + audioUri: string, + model: 'base' | 'advanced' = 'base' +): Promise { + const token = await getValidToken(); + if (!token) { + throw new Error('Token di autenticazione non disponibile'); + } + + const formData = new FormData(); + formData.append('model', model); + formData.append('audio', { + uri: audioUri, + name: getFileName(audioUri), + type: getContentType(audioUri), + } as any); + + const apiUrl = `${process.env.EXPO_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL}/chat/voice-command`; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + body: formData, + }); + + let data: QuickVoiceAddResponse | null = null; + try { + data = await response.json(); + } catch { + data = null; + } + + if (!response.ok) { + const detail = (data as any)?.detail || data?.error || `Errore HTTP ${response.status}`; + throw new Error(detail); + } + + return data ?? { received: false, error: 'Risposta non valida dal server' }; +}