From 3a4a55b78e92150cb11de83226fbfbe708269654 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 11 Sep 2025 19:11:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A5=AE=E9=A3=9F=E5=8A=9F=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=80=85=E8=B0=83=E8=AF=95=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成 @react-native-voice/voice 实现中文语音识别,支持“一句话记录”餐食 - 新增语音录制页面,含波形动画、音量反馈与识别结果确认 - FloatingFoodOverlay 新增语音入口,打通拍照/库/语音三种记录方式 - 添加麦克风与语音识别权限描述(iOS Info.plist 与 Android manifest) - 实现开发者模式:连续三次点击用户名激活,含日志查看、导出与清除 - 新增 logger 工具类,统一日志存储(AsyncStorage)与按级别输出 - 重构 BackgroundTaskManager 为单例并支持 Promise 初始化,避免重复注册 - 移除 sleep-detail 多余渐变背景,改用 ThemedView 统一主题 - 新增通用 haptic 反馈函数,支持多种震动类型(iOS only) - 升级 expo-background-task、expo-notifications、expo-task-manager 至兼容版本 --- app.json | 10 +- app/(tabs)/personal.tsx | 50 ++- app/(tabs)/statistics.tsx | 4 +- app/_layout.tsx | 23 +- app/developer.tsx | 148 +++++++++ app/developer/logs.tsx | 312 +++++++++++++++++++ app/sleep-detail.tsx | 24 +- app/voice-record.tsx | 471 +++++++++++++++++++++++++++++ components/FloatingFoodOverlay.tsx | 12 + constants/Routes.ts | 5 + ios/Podfile.lock | 18 +- ios/digitalpilates/Info.plist | 4 + package-lock.json | 251 ++++++++++++++- package.json | 7 +- services/backgroundTaskManager.ts | 28 +- utils/haptics.ts | 22 ++ utils/logger.ts | 113 +++++++ 17 files changed, 1427 insertions(+), 75 deletions(-) create mode 100644 app/developer.tsx create mode 100644 app/developer/logs.tsx create mode 100644 app/voice-record.tsx create mode 100644 utils/logger.ts diff --git a/app.json b/app.json index c2b7d98..a8aa748 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,8 @@ "NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。", "NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。", "NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。", + "NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。", + "NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。", "NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。", "UIBackgroundModes": [ "processing", @@ -35,7 +37,8 @@ "permissions": [ "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.VIBRATE", - "android.permission.WAKE_LOCK" + "android.permission.WAKE_LOCK", + "android.permission.RECORD_AUDIO" ] }, "web": { @@ -66,10 +69,7 @@ "expo-notifications", { "icon": "./assets/images/Sealife.jpeg", - "color": "#ffffff", - "sounds": [ - "./assets/sounds/notification.wav" - ] + "color": "#ffffff" } ], [ diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index fb56769..2052e9e 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,17 +1,19 @@ import ActivityHeatMap from '@/components/ActivityHeatMap'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; +import { ROUTES } from '@/constants/Routes'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useNotifications } from '@/hooks/useNotifications'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; +import { log } from '@/utils/logger'; import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -31,6 +33,11 @@ export default function PersonalScreen() { const [notificationEnabled, setNotificationEnabled] = useState(false); + // 开发者模式相关状态 + const [showDeveloperSection, setShowDeveloperSection] = useState(false); + const clickTimestamps = useRef([]); + const clickTimeoutRef = useRef(null); + // 计算底部间距 const bottomPadding = useMemo(() => { return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); @@ -86,6 +93,32 @@ export default function PersonalScreen() { loadNotificationPreference(); }, []); + // 处理用户名连续点击 + const handleUserNamePress = () => { + const now = Date.now(); + clickTimestamps.current.push(now); + + // 清除之前的超时 + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + + // 只保留最近1秒内的点击 + clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000); + + // 检查是否有3次连续点击 + if (clickTimestamps.current.length >= 3) { + setShowDeveloperSection(true); + clickTimestamps.current = []; // 清空点击记录 + log.info('开发者模式已激活'); + } else { + // 1秒后清空点击记录 + clickTimeoutRef.current = setTimeout(() => { + clickTimestamps.current = []; + }, 1000); + } + }; + // 处理通知开关变化 const handleNotificationToggle = async (value: boolean) => { if (value) { @@ -148,7 +181,9 @@ export default function PersonalScreen() { /> - {displayName} + + {displayName} + pushIfAuthedElseLogin('/profile/edit')}> 编辑 @@ -238,6 +273,17 @@ export default function PersonalScreen() { }, ], }, + // 开发者section(需要连续点击三次用户名激活) + ...(showDeveloperSection ? [{ + title: '开发者', + items: [ + { + icon: 'code-slash-outline' as const, + title: '开发者选项', + onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER), + }, + ], + }] : []), { title: '其他', items: [ diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index a825f3e..68f58f0 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -13,7 +13,7 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { backgroundTaskManager } from '@/services/backgroundTaskManager'; +import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; @@ -493,7 +493,7 @@ export default function ExploreScreen() { style={styles.debugButton} onPress={async () => { console.log('🔧 手动触发后台任务测试...'); - await backgroundTaskManager.triggerTaskForTesting(); + await BackgroundTaskManager.getInstance().triggerTaskForTesting(); }} > 🔧 diff --git a/app/_layout.tsx b/app/_layout.tsx index f601a76..bcb7f77 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -9,7 +9,6 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useQuickActions } from '@/hooks/useQuickActions'; import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; -import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { notificationService } from '@/services/notifications'; import { setupQuickActions } from '@/services/quickActions'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; @@ -24,8 +23,16 @@ import RNExitApp from 'react-native-exit-app'; import { DialogProvider } from '@/components/ui/DialogProvider'; import { ToastProvider } from '@/contexts/ToastContext'; +import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { Provider } from 'react-redux'; +let resolver: (() => void) | null; + +// Create a promise and store its resolve function for later +const promise = new Promise((resolve) => { + resolver = resolve; +}); + function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); const { privacyAgreed, profile } = useAppSelector((state) => state.user); @@ -42,14 +49,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { }; const initializeNotifications = async () => { try { + + await BackgroundTaskManager.getInstance().initialize(promise); // 初始化通知服务 await notificationService.initialize(); console.log('通知服务初始化成功'); - // 初始化后台任务管理器 - await backgroundTaskManager.initialize(); - console.log('后台任务管理器初始化成功'); - // 初始化快捷动作 await setupQuickActions(); console.log('快捷动作初始化成功'); @@ -62,7 +67,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { const widgetSync = await syncPendingWidgetChanges(); if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) { console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`); - + // 将待同步的记录添加到 Redux store for (const record of widgetSync.pendingRecords) { try { @@ -71,13 +76,13 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { recordedAt: record.recordedAt, source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget) })).unwrap(); - + console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`); } catch (error) { console.error('同步水记录失败:', error); } } - + // 清除已同步的记录 await clearPendingWaterRecords(); console.log('所有待同步的水记录已处理完成'); @@ -105,8 +110,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { const registerAllReminders = async () => { try { await notificationService.initialize(); - // 后台任务 - await backgroundTaskManager.initialize() // 注册午餐提醒(12:00) await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || ''); console.log('午餐提醒已注册'); diff --git a/app/developer.tsx b/app/developer.tsx new file mode 100644 index 0000000..4911da2 --- /dev/null +++ b/app/developer.tsx @@ -0,0 +1,148 @@ +import { ROUTES } from '@/constants/Routes'; +import { useRouter } from 'expo-router'; +import React from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; + +export default function DeveloperScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const developerItems = [ + { + title: '日志', + subtitle: '查看应用运行日志', + icon: 'document-text-outline', + onPress: () => router.push(ROUTES.DEVELOPER_LOGS), + }, + ]; + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + 开发者 + + + + + + + {developerItems.map((item, index) => ( + + + + + + + {item.title} + {item.subtitle} + + + + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8F9FA', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + color: '#2C3E50', + }, + placeholder: { + width: 40, + }, + scrollView: { + flex: 1, + }, + content: { + padding: 16, + }, + cardContainer: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: '#F1F3F4', + }, + menuItemLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 8, + backgroundColor: 'rgba(147, 112, 219, 0.1)', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + menuItemTitle: { + fontSize: 16, + color: '#2C3E50', + fontWeight: '600', + marginBottom: 2, + }, + menuItemSubtitle: { + fontSize: 13, + color: '#6B7280', + }, +}); \ No newline at end of file diff --git a/app/developer/logs.tsx b/app/developer/logs.tsx new file mode 100644 index 0000000..d6a65ee --- /dev/null +++ b/app/developer/logs.tsx @@ -0,0 +1,312 @@ +import { log, logger, LogEntry } from '@/utils/logger'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Alert, + FlatList, + RefreshControl, + Share, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function LogsScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [logs, setLogs] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const loadLogs = async () => { + try { + const allLogs = await logger.getAllLogs(); + // 按时间倒序排列,最新的在前面 + setLogs(allLogs.reverse()); + } catch (error) { + log.error('加载日志失败', error); + } + }; + + const onRefresh = async () => { + setRefreshing(true); + await loadLogs(); + setRefreshing(false); + }; + + const handleClearLogs = () => { + Alert.alert( + '清除日志', + '确定要清除所有日志吗?此操作不可恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '确定', + style: 'destructive', + onPress: async () => { + await logger.clearLogs(); + setLogs([]); + log.info('日志已清除'); + }, + }, + ] + ); + }; + + const handleExportLogs = async () => { + try { + const exportData = await logger.exportLogs(); + await Share.share({ + message: exportData, + title: '应用日志导出', + }); + } catch (error) { + log.error('导出日志失败', error); + Alert.alert('错误', '导出日志失败'); + } + }; + + useEffect(() => { + loadLogs(); + // 添加测试日志 + log.info('进入日志页面'); + }, []); + + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const getLevelColor = (level: string) => { + switch (level) { + case 'ERROR': + return '#FF4444'; + case 'WARN': + return '#FF8800'; + case 'INFO': + return '#0088FF'; + case 'DEBUG': + return '#888888'; + default: + return '#333333'; + } + }; + + const getLevelIcon = (level: string) => { + switch (level) { + case 'ERROR': + return 'close-circle'; + case 'WARN': + return 'warning'; + case 'INFO': + return 'information-circle'; + case 'DEBUG': + return 'bug'; + default: + return 'ellipse'; + } + }; + + const renderLogItem = ({ item }: { item: LogEntry }) => ( + + + + + + {item.level} + + + {formatTimestamp(item.timestamp)} + + {item.message} + {item.data && ( + {JSON.stringify(item.data, null, 2)} + )} + + ); + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + 日志 ({logs.length}) + + + + + + + + + + + {/* Logs List */} + item.id} + style={styles.logsList} + contentContainerStyle={styles.logsContent} + refreshControl={ + + } + ListEmptyComponent={ + + + 暂无日志 + { + log.debug('测试调试日志'); + log.info('测试信息日志'); + log.warn('测试警告日志'); + log.error('测试错误日志'); + setTimeout(loadLogs, 100); + }} + > + 生成测试日志 + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8F9FA', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + color: '#2C3E50', + flex: 1, + marginLeft: 8, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + actionButton: { + width: 36, + height: 36, + justifyContent: 'center', + alignItems: 'center', + marginLeft: 8, + }, + logsList: { + flex: 1, + }, + logsContent: { + padding: 16, + }, + logItem: { + backgroundColor: '#FFFFFF', + padding: 12, + marginBottom: 8, + borderRadius: 8, + borderLeftWidth: 3, + borderLeftColor: '#E5E7EB', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + logHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 6, + }, + logLevelContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + logIcon: { + marginRight: 4, + }, + logLevel: { + fontSize: 12, + fontWeight: 'bold', + fontFamily: 'monospace', + }, + timestamp: { + fontSize: 11, + color: '#9CA3AF', + fontFamily: 'monospace', + }, + logMessage: { + fontSize: 14, + color: '#374151', + lineHeight: 20, + fontFamily: 'monospace', + }, + logData: { + fontSize: 12, + color: '#6B7280', + marginTop: 8, + padding: 8, + backgroundColor: '#F3F4F6', + borderRadius: 4, + fontFamily: 'monospace', + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 48, + }, + emptyText: { + fontSize: 16, + color: '#9CA3AF', + marginTop: 12, + marginBottom: 24, + }, + testButton: { + backgroundColor: '#9370DB', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + }, + testButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx index ca391f1..16effd8 100644 --- a/app/sleep-detail.tsx +++ b/app/sleep-detail.tsx @@ -8,7 +8,6 @@ import { } from '@/utils/sleepHealthKit'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; -import { LinearGradient } from 'expo-linear-gradient'; import { router, useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useState } from 'react'; import { @@ -19,6 +18,7 @@ import { TouchableOpacity, View } from 'react-native'; +import { ThemedView } from '@/components/ThemedView'; import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal'; import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal'; @@ -110,21 +110,13 @@ export default function SleepDetailScreen() { }; return ( - - {/* 背景渐变 */} - - + {/* 顶部导航 */} router.back()} - withSafeTop={true} transparent={true} + variant="default" /> @@ -392,21 +384,13 @@ export default function SleepDetailScreen() { visible={sleepStagesModal.visible} onClose={() => setSleepStagesModal({ visible: false })} /> - + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F8FAFC', - }, - gradientBackground: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, }, scrollView: { flex: 1, diff --git a/app/voice-record.tsx b/app/voice-record.tsx new file mode 100644 index 0000000..bc4bf51 --- /dev/null +++ b/app/voice-record.tsx @@ -0,0 +1,471 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { triggerHapticFeedback } from '@/utils/haptics'; +import { Ionicons } from '@expo/vector-icons'; +import Voice from '@react-native-voice/voice'; +import { BlurView } from 'expo-blur'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Animated, + Dimensions, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; + +const { width } = Dimensions.get('window'); + +type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result'; + +export default function VoiceRecordScreen() { + const theme = useColorScheme() ?? 'light'; + const colorTokens = Colors[theme]; + const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>(); + + // 状态管理 + const [recordState, setRecordState] = useState('idle'); + const [recognizedText, setRecognizedText] = useState(''); + const [isListening, setIsListening] = useState(false); + + // 动画相关 + const scaleAnimation = useRef(new Animated.Value(1)).current; + const pulseAnimation = useRef(new Animated.Value(1)).current; + const waveAnimation = useRef(new Animated.Value(0)).current; + + useEffect(() => { + // 初始化语音识别 + Voice.onSpeechStart = onSpeechStart; + Voice.onSpeechRecognized = onSpeechRecognized; + Voice.onSpeechEnd = onSpeechEnd; + Voice.onSpeechError = onSpeechError; + Voice.onSpeechResults = onSpeechResults; + Voice.onSpeechPartialResults = onSpeechPartialResults; + Voice.onSpeechVolumeChanged = onSpeechVolumeChanged; + + return () => { + Voice.destroy().then(Voice.removeAllListeners); + }; + }, []); + + // 启动脉动动画 + const startPulseAnimation = () => { + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnimation, { + toValue: 1.2, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnimation, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]) + ).start(); + }; + + // 启动波浪动画 + const startWaveAnimation = () => { + Animated.loop( + Animated.timing(waveAnimation, { + toValue: 1, + duration: 1500, + useNativeDriver: false, + }) + ).start(); + }; + + // 停止所有动画 + const stopAnimations = () => { + pulseAnimation.stopAnimation(); + waveAnimation.stopAnimation(); + scaleAnimation.setValue(1); + pulseAnimation.setValue(1); + waveAnimation.setValue(0); + }; + + // 语音识别回调 + const onSpeechStart = () => { + setIsListening(true); + setRecordState('listening'); + startPulseAnimation(); + startWaveAnimation(); + }; + + const onSpeechRecognized = () => { + console.log('语音识别中...'); + }; + + const onSpeechEnd = () => { + setIsListening(false); + setRecordState('processing'); + stopAnimations(); + }; + + const onSpeechError = (error: any) => { + console.log('语音识别错误:', error); + setIsListening(false); + setRecordState('idle'); + stopAnimations(); + Alert.alert('录音失败', '请检查麦克风权限或稍后重试'); + }; + + const onSpeechResults = (event: any) => { + const text = event.value?.[0] || ''; + setRecognizedText(text); + setRecordState('result'); + stopAnimations(); + }; + + const onSpeechPartialResults = (event: any) => { + const text = event.value?.[0] || ''; + setRecognizedText(text); + }; + + const onSpeechVolumeChanged = (event: any) => { + // 根据音量调整动画 + const volume = event.value || 0; + const scale = 1 + (volume * 0.1); + scaleAnimation.setValue(Math.min(scale, 1.5)); + }; + + // 开始录音 + const startRecording = async () => { + try { + setRecognizedText(''); + setRecordState('idle'); + triggerHapticFeedback('impactMedium'); + + await Voice.start('zh-CN'); // 设置为中文识别 + } catch (error) { + console.log('启动语音识别失败:', error); + Alert.alert('录音失败', '无法启动语音识别,请检查权限设置'); + } + }; + + // 停止录音 + const stopRecording = async () => { + try { + await Voice.stop(); + triggerHapticFeedback('impactLight'); + } catch (error) { + console.log('停止语音识别失败:', error); + } + }; + + // 重新录音 + const retryRecording = () => { + setRecognizedText(''); + setRecordState('idle'); + startRecording(); + }; + + // 确认并返回结果 + const confirmResult = () => { + triggerHapticFeedback('impactMedium'); + // TODO: 处理识别结果,可以传递给食物分析页面 + router.back(); + }; + + const handleBack = () => { + if (isListening) { + stopRecording(); + } + router.back(); + }; + + // 获取状态对应的UI文本 + const getStatusText = () => { + switch (recordState) { + case 'idle': + return '点击开始录音'; + case 'listening': + return '正在聆听...'; + case 'processing': + return 'AI处理中...'; + case 'result': + return '识别完成'; + default: + return ''; + } + }; + + // 获取主按钮配置 + const getMainButtonConfig = () => { + switch (recordState) { + case 'idle': + return { + onPress: startRecording, + color: '#7B68EE', + icon: 'mic', + size: 80, + }; + case 'listening': + return { + onPress: stopRecording, + color: '#FF6B6B', + icon: 'stop', + size: 80, + }; + case 'processing': + return { + onPress: () => { }, + color: '#FFA07A', + icon: 'hourglass', + size: 80, + }; + case 'result': + return { + onPress: confirmResult, + color: '#4ECDC4', + icon: 'checkmark', + size: 80, + }; + } + }; + + const buttonConfig = getMainButtonConfig(); + + return ( + + + + + {/* 录音动画区域 */} + + {/* 背景波浪效果 */} + {recordState === 'listening' && ( + <> + {[1, 2, 3].map((index) => ( + + ))} + + )} + + {/* 主录音按钮 */} + + + + + + + + {/* 状态文本 */} + + + {getStatusText()} + + + {recordState === 'listening' && ( + + 说出您想记录的食物内容 + + )} + + + {/* 识别结果 */} + {recognizedText && ( + + + + 识别结果: + + + {recognizedText} + + + {recordState === 'result' && ( + + + + 重新录音 + + + + + 确认使用 + + + )} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, + }, + animationContainer: { + alignItems: 'center', + justifyContent: 'center', + marginBottom: 40, + height: 200, + width: 200, + }, + waveRing: { + position: 'absolute', + width: 160, + height: 160, + borderRadius: 80, + backgroundColor: 'rgba(123, 104, 238, 0.1)', + borderWidth: 1, + borderColor: 'rgba(123, 104, 238, 0.2)', + }, + recordButton: { + width: 160, + height: 160, + borderRadius: 80, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 12, + }, + recordButtonInner: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 80, + }, + statusContainer: { + alignItems: 'center', + marginBottom: 30, + }, + statusText: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + hintText: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, + resultContainer: { + position: 'absolute', + bottom: 100, + left: 20, + right: 20, + borderRadius: 16, + overflow: 'hidden', + }, + resultContent: { + padding: 20, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderRadius: 16, + }, + resultLabel: { + fontSize: 12, + fontWeight: '500', + marginBottom: 8, + }, + resultText: { + fontSize: 16, + fontWeight: '600', + lineHeight: 24, + marginBottom: 16, + }, + resultActions: { + flexDirection: 'row', + gap: 12, + }, + actionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + gap: 6, + }, + retryButton: { + backgroundColor: 'rgba(123, 104, 238, 0.1)', + borderWidth: 1, + borderColor: '#7B68EE', + }, + confirmButton: { + backgroundColor: '#7B68EE', + }, + retryButtonText: { + fontSize: 14, + fontWeight: '500', + color: '#7B68EE', + }, + confirmButtonText: { + fontSize: 14, + fontWeight: '500', + color: 'white', + }, +}); \ No newline at end of file diff --git a/components/FloatingFoodOverlay.tsx b/components/FloatingFoodOverlay.tsx index 1f3c6c3..cf80291 100644 --- a/components/FloatingFoodOverlay.tsx +++ b/components/FloatingFoodOverlay.tsx @@ -30,6 +30,11 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F router.push(`/food/camera?mealType=${mealType}`); }; + const handleVoiceRecord = () => { + onClose(); + router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`); + }; + const menuItems = [ { id: 'scan', @@ -45,6 +50,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F backgroundColor: '#FF9500', onPress: handleFoodLibrary, }, + { + id: 'voice-record', + title: '一句话记录', + icon: '🎤', + backgroundColor: '#7B68EE', + onPress: handleVoiceRecord, + }, ]; return ( diff --git a/constants/Routes.ts b/constants/Routes.ts index eeeb3fa..4a534f7 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -37,6 +37,7 @@ export const ROUTES = { // 营养相关路由 NUTRITION_RECORDS: '/nutrition/records', FOOD_LIBRARY: '/food-library', + VOICE_RECORD: '/voice-record', // 体重记录相关路由 WEIGHT_RECORDS: '/weight-records', @@ -50,6 +51,10 @@ export const ROUTES = { // 目标管理路由 (已移至tab中) // GOAL_MANAGEMENT: '/goal-management', + + // 开发者相关路由 + DEVELOPER: '/developer', + DEVELOPER_LOGS: '/developer/logs', } as const; // 路由参数常量 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cb2afde..01ae7f5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -40,7 +40,7 @@ PODS: - ExpoModulesCore - ExpoAsset (11.1.7): - ExpoModulesCore - - ExpoBackgroundTask (0.2.8): + - ExpoBackgroundTask (1.0.6): - ExpoModulesCore - ExpoBlur (14.1.5): - ExpoModulesCore @@ -104,7 +104,7 @@ PODS: - ExpoModulesCore - ExpoWebBrowser (14.2.0): - ExpoModulesCore - - EXTaskManager (13.1.6): + - EXTaskManager (14.0.6): - ExpoModulesCore - UMAppLoader - fast_float (6.1.4) @@ -1425,6 +1425,8 @@ PODS: - React-Core - react-native-safe-area-context (5.4.0): - React-Core + - react-native-voice (3.2.4): + - React-Core - react-native-webview (13.13.5): - DoubleConversion - glog @@ -1987,7 +1989,7 @@ PODS: - SDWebImage/Core (~> 5.17) - Sentry/HybridSDK (8.53.2) - SocketRocket (0.7.1) - - UMAppLoader (5.1.3) + - UMAppLoader (6.0.6) - Yoga (0.0.0) - ZXingObjC/Core (3.6.9) - ZXingObjC/OneD (3.6.9): @@ -2065,6 +2067,7 @@ DEPENDENCIES: - react-native-cos-sdk (from `../node_modules/react-native-cos-sdk`) - react-native-render-html (from `../node_modules/react-native-render-html`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-voice (from `../node_modules/@react-native-voice/voice`)" - react-native-webview (from `../node_modules/react-native-webview`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -2266,6 +2269,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-render-html" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-voice: + :path: "../node_modules/@react-native-voice/voice" react-native-webview: :path: "../node_modules/react-native-webview" React-NativeModulesApple: @@ -2369,7 +2374,7 @@ SPEC CHECKSUMS: Expo: c9e30ab79606b3800733594a961528bc4abb0ffe ExpoAppleAuthentication: 4d2e0c88a4463229760f1fbb9a937a810efb6863 ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6 - ExpoBackgroundTask: 6c1990438e45b5c4bbbc7d75aa6b688d53602fe8 + ExpoBackgroundTask: f4dac8f09f3b187e464af7a1088d9fd5ae48a836 ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9 ExpoCamera: e1879906d41184e84b57d7643119f8509414e318 ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63 @@ -2387,7 +2392,7 @@ SPEC CHECKSUMS: ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859 ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92 - EXTaskManager: 280143f6d8e596f28739d74bf34910300dcbd4ea + EXTaskManager: eedcd03c1a574c47d3f48d83d4e4659b3c1fa29b fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd @@ -2435,6 +2440,7 @@ SPEC CHECKSUMS: react-native-cos-sdk: a29ad87f60e2edb2adc46da634aa5b6e7cd14e35 react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1 + react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954 React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618 React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd @@ -2486,7 +2492,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - UMAppLoader: 55159b69750129faa7a51c493cb8ea55a7b64eb9 + UMAppLoader: 2af2cc05fcaa9851233893c0e3dbc56a99f57e36 Yoga: adb397651e1c00672c12e9495babca70777e411e ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index c747a16..4cdc19e 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -63,6 +63,10 @@ 应用需要访问相册以选择您的体态照片用于AI测评。 NSUserNotificationsUsageDescription 应用需要发送通知以提醒您喝水和站立活动。 + NSMicrophoneUsageDescription + 应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。 + NSSpeechRecognitionUsageDescription + 应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。 NSUserActivityTypes $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route diff --git a/package-lock.json b/package-lock.json index bf32659..3ab0a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-native-community/datetimepicker": "^8.4.4", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "^2.11.1", + "@react-native-voice/voice": "^3.2.4", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -23,7 +24,7 @@ "dayjs": "^1.11.13", "expo": "53.0.22", "expo-apple-authentication": "~7.2.4", - "expo-background-task": "~0.2.8", + "expo-background-task": "~1.0.6", "expo-blur": "~14.1.5", "expo-camera": "^16.1.11", "expo-constants": "~17.1.7", @@ -40,7 +41,7 @@ "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", "expo-system-ui": "~5.0.11", - "expo-task-manager": "~13.1.6", + "expo-task-manager": "~14.0.6", "expo-web-browser": "~14.2.0", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", @@ -2972,6 +2973,167 @@ "react-native": "*" } }, + "node_modules/@react-native-voice/voice": { + "version": "3.2.4", + "resolved": "https://mirrors.tencent.com/npm/@react-native-voice/voice/-/voice-3.2.4.tgz", + "integrity": "sha512-4i3IpB/W5VxCI7BQZO5Nr2VB0ecx0SLvkln2Gy29cAQKqgBl+1ZsCwUBChwHlPbmja6vA3tp/+2ADQGwB1OhHg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^2.0.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react-native": ">= 0.60.2" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://mirrors.tencent.com/npm/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/config-plugins": { + "version": "2.0.4", + "resolved": "https://mirrors.tencent.com/npm/@expo/config-plugins/-/config-plugins-2.0.4.tgz", + "integrity": "sha512-JGt/X2tFr7H8KBQrKfbGo9hmCubQraMxq5sj3bqDdKmDOLcE1a/EDCP9g0U4GHsa425J8VDIkQUHYz3h3ndEXQ==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^41.0.0", + "@expo/json-file": "8.2.30", + "@expo/plist": "0.0.13", + "debug": "^4.3.1", + "find-up": "~5.0.0", + "fs-extra": "9.0.0", + "getenv": "^1.0.0", + "glob": "7.1.6", + "resolve-from": "^5.0.0", + "slash": "^3.0.0", + "xcode": "^3.0.1", + "xml2js": "^0.4.23" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/config-types": { + "version": "41.0.0", + "resolved": "https://mirrors.tencent.com/npm/@expo/config-types/-/config-types-41.0.0.tgz", + "integrity": "sha512-Ax0pHuY5OQaSrzplOkT9DdpdmNzaVDnq9VySb4Ujq7UJ4U4jriLy8u93W98zunOXpcu0iiKubPsqD6lCiq0pig==", + "license": "MIT" + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/json-file": { + "version": "8.2.30", + "resolved": "https://mirrors.tencent.com/npm/@expo/json-file/-/json-file-8.2.30.tgz", + "integrity": "sha512-vrgGyPEXBoFI5NY70IegusCSoSVIFV3T3ry4tjJg1MFQKTUlR7E0r+8g8XR6qC705rc2PawaZQjqXMAVtV6s2A==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "fs-extra": "9.0.0", + "json5": "^1.0.1", + "write-file-atomic": "^2.3.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/plist": { + "version": "0.0.13", + "resolved": "https://mirrors.tencent.com/npm/@expo/plist/-/plist-0.0.13.tgz", + "integrity": "sha512-zGPSq9OrCn7lWvwLLHLpHUUq2E40KptUFXn53xyZXPViI0k9lbApcR9KlonQZ95C+ELsf0BQ3gRficwK92Ivcw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0", + "xmldom": "~0.5.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/getenv": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/getenv/-/getenv-1.0.0.tgz", + "integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-native-voice/voice/node_modules/glob": { + "version": "7.1.6", + "resolved": "http://mirrors.tencent.com/npm/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native-voice/voice/node_modules/json5": { + "version": "1.0.2", + "resolved": "http://mirrors.tencent.com/npm/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/@react-native-voice/voice/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://mirrors.tencent.com/npm/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@react-native-voice/voice/node_modules/write-file-atomic": { + "version": "2.4.3", + "resolved": "https://mirrors.tencent.com/npm/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@react-native-voice/voice/node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://mirrors.tencent.com/npm/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://mirrors.tencent.com/npm/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/xmlbuilder": { + "version": "14.0.0", + "resolved": "http://mirrors.tencent.com/npm/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.79.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", @@ -4969,6 +5131,15 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7114,12 +7285,12 @@ } }, "node_modules/expo-background-task": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-0.2.8.tgz", - "integrity": "sha512-dePyskpmyDZeOtbr9vWFh+Nrse0TvF6YitJqnKcd+3P7pDMiDr1V2aT6zHdNOc5iV9vPaDJoH/zdmlarp1uHMQ==", + "version": "1.0.6", + "resolved": "https://mirrors.tencent.com/npm/expo-background-task/-/expo-background-task-1.0.6.tgz", + "integrity": "sha512-/lGXfpetLtFkAzfLyoVdagB9XMaMkXUgjRy/rLnZy5bXlTK3d7AxITzYs4ePQWRaJ89MPeOWokXg1cy3JgwOeA==", "license": "MIT", "dependencies": { - "expo-task-manager": "~13.1.6" + "expo-task-manager": "~14.0.6" }, "peerDependencies": { "expo": "*" @@ -7456,12 +7627,12 @@ "license": "MIT" }, "node_modules/expo-task-manager": { - "version": "13.1.6", - "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-13.1.6.tgz", - "integrity": "sha512-sYNAftpIeZ+j6ur17Jo0OpSTk9ks/MDvTbrNCimXMyjIt69XXYL/kAPYf76bWuxOuN8bcJ8Ef8YvihkwFG9hDA==", + "version": "14.0.6", + "resolved": "https://mirrors.tencent.com/npm/expo-task-manager/-/expo-task-manager-14.0.6.tgz", + "integrity": "sha512-3JVLgnhD7P23wpZxBGmK+GHD2Wy57snAENAOymoVHURgk2EkdgEfbVl2lcsFtr6UyACF/6i/aIAKW3mWpUGx+A==", "license": "MIT", "dependencies": { - "unimodules-app-loader": "~5.1.3" + "unimodules-app-loader": "~6.0.6" }, "peerDependencies": { "expo": "*", @@ -7799,6 +7970,21 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "9.0.0", + "resolved": "https://mirrors.tencent.com/npm/fs-extra/-/fs-extra-9.0.0.tgz", + "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9310,6 +9496,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://mirrors.tencent.com/npm/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://mirrors.tencent.com/npm/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -13892,9 +14099,9 @@ } }, "node_modules/unimodules-app-loader": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-5.1.3.tgz", - "integrity": "sha512-nPUkwfkpJWvdOQrVvyQSUol93/UdmsCVd9Hkx9RgAevmKSVYdZI+S87W73NGKl6QbwK9L1BDSY5OrQuo8Oq15g==", + "version": "6.0.6", + "resolved": "https://mirrors.tencent.com/npm/unimodules-app-loader/-/unimodules-app-loader-6.0.6.tgz", + "integrity": "sha512-VpXX3H9QXP5qp1Xe0JcR2S/+i2VOuiOmyVi6q6S0hFFXcHBXoFYOcBnQDkqU/JXogZt3Wf/HSM0NGuiL3MhZIQ==", "license": "MIT" }, "node_modules/unique-string": { @@ -13909,6 +14116,15 @@ "node": ">=8" } }, + "node_modules/universalify": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -14447,6 +14663,15 @@ "node": ">=8.0" } }, + "node_modules/xmldom": { + "version": "0.5.0", + "resolved": "https://mirrors.tencent.com/npm/xmldom/-/xmldom-0.5.0.tgz", + "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 33bfcd8..745c9d1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@react-native-community/datetimepicker": "^8.4.4", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "^2.11.1", + "@react-native-voice/voice": "^3.2.4", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -27,7 +28,7 @@ "dayjs": "^1.11.13", "expo": "53.0.22", "expo-apple-authentication": "~7.2.4", - "expo-background-task": "~0.2.8", + "expo-background-task": "~1.0.6", "expo-blur": "~14.1.5", "expo-camera": "^16.1.11", "expo-constants": "~17.1.7", @@ -37,14 +38,14 @@ "expo-image-picker": "~16.1.4", "expo-linear-gradient": "^14.1.5", "expo-linking": "~7.1.7", - "expo-notifications": "~0.31.4", + "expo-notifications": "~0.32.10", "expo-quick-actions": "^5.0.0", "expo-router": "~5.1.5", "expo-splash-screen": "~0.30.10", "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", "expo-system-ui": "~5.0.11", - "expo-task-manager": "~13.1.6", + "expo-task-manager": "~14.0.6", "expo-web-browser": "~14.2.0", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index 39d8026..40cfa91 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -1,11 +1,12 @@ import { store } from '@/store'; +import { log } from '@/utils/logger'; import { StandReminderHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as BackgroundTask from 'expo-background-task'; import * as TaskManager from 'expo-task-manager'; import { TaskManagerTaskBody } from 'expo-task-manager'; -const BACKGROUND_TASK_IDENTIFIER = 'background-task'; +export const BACKGROUND_TASK_IDENTIFIER = 'com.expo.modules.backgroundtask.processing'; @@ -183,36 +184,40 @@ export class BackgroundTaskManager { /** * 初始化后台任务管理器 */ - async initialize(): Promise { + async initialize(innerAppMountedPromise: Promise): Promise { if (this.isInitialized) { console.log('后台任务管理器已初始化'); return; } try { - // 定义后台任务 TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => { try { - console.log('[BackgroundTask] 后台任务执行'); + log.info('[BackgroundTask] 后台任务执行'); await executeBackgroundTasks(); - return BackgroundTask.BackgroundTaskResult.Success; + // return BackgroundTask.BackgroundTaskResult.Success; } catch (error) { console.error('[BackgroundTask] 任务执行失败:', error); - return BackgroundTask.BackgroundTaskResult.Failed; + // return BackgroundTask.BackgroundTaskResult.Failed; } }); + if (await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_IDENTIFIER)) { + log.info('[BackgroundTask] 任务已注册'); + return + } + + log.info('[BackgroundTask] 任务未注册, 开始注册...'); // 注册后台任务 - const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, { + await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, { minimumInterval: 15, }); - console.log('[BackgroundTask] 配置状态:', status); this.isInitialized = true; - console.log('后台任务管理器初始化完成'); + log.info('后台任务管理器初始化完成'); } catch (error) { console.error('初始化后台任务管理器失败:', error); @@ -299,11 +304,6 @@ export class BackgroundTaskManager { } } -/** - * 后台任务管理器单例实例 - */ -export const backgroundTaskManager = BackgroundTaskManager.getInstance(); - /** * 后台任务事件类型 */ diff --git a/utils/haptics.ts b/utils/haptics.ts index 7d4e3c8..331e34c 100644 --- a/utils/haptics.ts +++ b/utils/haptics.ts @@ -53,4 +53,26 @@ export const triggerErrorHaptic = () => { if (Platform.OS === 'ios') { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } +}; + +/** + * 通用震动反馈函数 (仅在 iOS 上生效) + */ +export const triggerHapticFeedback = (type: 'impactLight' | 'impactMedium' | 'impactHeavy' | 'success' | 'warning' | 'error') => { + if (Platform.OS === 'ios') { + switch (type) { + case 'impactLight': + return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + case 'impactMedium': + return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + case 'impactHeavy': + return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + case 'success': + return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + case 'warning': + return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + case 'error': + return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + } }; \ No newline at end of file diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..d601ef8 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,113 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +interface LogEntry { + id: string; + timestamp: number; + level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; + message: string; + data?: any; +} + +class Logger { + private static instance: Logger; + private readonly maxLogs = 1000; // 最多保存1000条日志 + private readonly storageKey = '@app_logs'; + + static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + private async getLogs(): Promise { + try { + const logsJson = await AsyncStorage.getItem(this.storageKey); + return logsJson ? JSON.parse(logsJson) : []; + } catch (error) { + console.error('Failed to get logs from storage:', error); + return []; + } + } + + private async saveLogs(logs: LogEntry[]): Promise { + try { + // 只保留最新的maxLogs条日志 + const trimmedLogs = logs.slice(-this.maxLogs); + await AsyncStorage.setItem(this.storageKey, JSON.stringify(trimmedLogs)); + } catch (error) { + console.error('Failed to save logs to storage:', error); + } + } + + private async addLog(level: LogEntry['level'], message: string, data?: any): Promise { + const logEntry: LogEntry = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + timestamp: Date.now(), + level, + message, + data + }; + + // 同时在控制台输出 + const logMethod = level === 'ERROR' ? console.error : + level === 'WARN' ? console.warn : + level === 'INFO' ? console.info : console.log; + + logMethod(`[${level}] ${message}`, data || ''); + + try { + const logs = await this.getLogs(); + logs.push(logEntry); + await this.saveLogs(logs); + } catch (error) { + console.error('Failed to add log:', error); + } + } + + async debug(message: string, data?: any): Promise { + await this.addLog('DEBUG', message, data); + } + + async info(message: string, data?: any): Promise { + await this.addLog('INFO', message, data); + } + + async warn(message: string, data?: any): Promise { + await this.addLog('WARN', message, data); + } + + async error(message: string, data?: any): Promise { + await this.addLog('ERROR', message, data); + } + + async getAllLogs(): Promise { + return await this.getLogs(); + } + + async clearLogs(): Promise { + try { + await AsyncStorage.removeItem(this.storageKey); + } catch (error) { + console.error('Failed to clear logs:', error); + } + } + + async exportLogs(): Promise { + const logs = await this.getLogs(); + return JSON.stringify(logs, null, 2); + } +} + +// 导出全局日志实例和便捷函数 +export const logger = Logger.getInstance(); + +// 便捷的全局日志函数 +export const log = { + debug: (message: string, data?: any) => logger.debug(message, data), + info: (message: string, data?: any) => logger.info(message, data), + warn: (message: string, data?: any) => logger.warn(message, data), + error: (message: string, data?: any) => logger.error(message, data), +}; + +export type { LogEntry }; \ No newline at end of file