feat: 新增语音记录饮食功能与开发者调试模块
- 集成 @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 至兼容版本
This commit is contained in:
10
app.json
10
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"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
const clickTimeoutRef = useRef<number | null>(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() {
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.userDetails}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>编辑</Text>
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
|
||||
@@ -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<void>((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('午餐提醒已注册');
|
||||
|
||||
148
app/developer.tsx
Normal file
148
app/developer.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>开发者</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.cardContainer}>
|
||||
{developerItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
index === developerItems.length - 1 && { borderBottomWidth: 0 }
|
||||
]}
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color="#9370DB"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.menuItemTitle}>{item.title}</Text>
|
||||
<Text style={styles.menuItemSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
312
app/developer/logs.tsx
Normal file
312
app/developer/logs.tsx
Normal file
@@ -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<LogEntry[]>([]);
|
||||
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 }) => (
|
||||
<View style={styles.logItem}>
|
||||
<View style={styles.logHeader}>
|
||||
<View style={styles.logLevelContainer}>
|
||||
<Ionicons
|
||||
name={getLevelIcon(item.level) as any}
|
||||
size={16}
|
||||
color={getLevelColor(item.level)}
|
||||
style={styles.logIcon}
|
||||
/>
|
||||
<Text style={[styles.logLevel, { color: getLevelColor(item.level) }]}>
|
||||
{item.level}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimestamp(item.timestamp)}</Text>
|
||||
</View>
|
||||
<Text style={styles.logMessage}>{item.message}</Text>
|
||||
{item.data && (
|
||||
<Text style={styles.logData}>{JSON.stringify(item.data, null, 2)}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>日志 ({logs.length})</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
|
||||
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleClearLogs} style={styles.actionButton}>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Logs List */}
|
||||
<FlatList
|
||||
data={logs}
|
||||
renderItem={renderLogItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={styles.logsList}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="document-text-outline" size={48} color="#CCCCCC" />
|
||||
<Text style={styles.emptyText}>暂无日志</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.testButton}
|
||||
onPress={() => {
|
||||
log.debug('测试调试日志');
|
||||
log.info('测试信息日志');
|
||||
log.warn('测试警告日志');
|
||||
log.error('测试错误日志');
|
||||
setTimeout(loadLogs, 100);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.testButtonText}>生成测试日志</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f0f4ff', '#e6f2ff', '#ffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<ThemedView style={styles.container}>
|
||||
{/* 顶部导航 */}
|
||||
<HeaderBar
|
||||
title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={true}
|
||||
transparent={true}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
|
||||
@@ -392,21 +384,13 @@ export default function SleepDetailScreen() {
|
||||
visible={sleepStagesModal.visible}
|
||||
onClose={() => setSleepStagesModal({ visible: false })}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
||||
471
app/voice-record.tsx
Normal file
471
app/voice-record.tsx
Normal file
@@ -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<VoiceRecordState>('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 (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar
|
||||
title="一句话记录"
|
||||
onBack={handleBack}
|
||||
tone={theme}
|
||||
variant="elevated"
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 录音动画区域 */}
|
||||
<View style={styles.animationContainer}>
|
||||
{/* 背景波浪效果 */}
|
||||
{recordState === 'listening' && (
|
||||
<>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.waveRing,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: waveAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.8, 2 + index * 0.3],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: waveAnimation.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0.6, 0.3, 0],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 主录音按钮 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.recordButton,
|
||||
{
|
||||
backgroundColor: buttonConfig.color,
|
||||
transform: [
|
||||
{ scale: scaleAnimation },
|
||||
{ scale: pulseAnimation },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButtonInner}
|
||||
onPress={buttonConfig.onPress}
|
||||
activeOpacity={0.8}
|
||||
disabled={recordState === 'processing'}
|
||||
>
|
||||
<Ionicons
|
||||
name={buttonConfig.icon as any}
|
||||
size={buttonConfig.size}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 状态文本 */}
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={[styles.statusText, { color: colorTokens.text }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
|
||||
{recordState === 'listening' && (
|
||||
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
|
||||
说出您想记录的食物内容
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 识别结果 */}
|
||||
{recognizedText && (
|
||||
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
|
||||
<View style={styles.resultContent}>
|
||||
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
|
||||
识别结果:
|
||||
</Text>
|
||||
<Text style={[styles.resultText, { color: colorTokens.text }]}>
|
||||
{recognizedText}
|
||||
</Text>
|
||||
|
||||
{recordState === 'result' && (
|
||||
<View style={styles.resultActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.retryButton]}
|
||||
onPress={retryRecording}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color="#7B68EE" />
|
||||
<Text style={styles.retryButtonText}>重新录音</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.confirmButton]}
|
||||
onPress={confirmResult}
|
||||
>
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
<Text style={styles.confirmButtonText}>确认使用</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</BlurView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 路由参数常量
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
<string>应用需要访问相册以选择您的体态照片用于AI测评。</string>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>应用需要发送通知以提醒您喝水和站立活动。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||
|
||||
251
package-lock.json
generated
251
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
async initialize(innerAppMountedPromise: Promise<void>): Promise<void> {
|
||||
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();
|
||||
|
||||
/**
|
||||
* 后台任务事件类型
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
113
utils/logger.ts
Normal file
113
utils/logger.ts
Normal file
@@ -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<LogEntry[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.addLog('DEBUG', message, data);
|
||||
}
|
||||
|
||||
async info(message: string, data?: any): Promise<void> {
|
||||
await this.addLog('INFO', message, data);
|
||||
}
|
||||
|
||||
async warn(message: string, data?: any): Promise<void> {
|
||||
await this.addLog('WARN', message, data);
|
||||
}
|
||||
|
||||
async error(message: string, data?: any): Promise<void> {
|
||||
await this.addLog('ERROR', message, data);
|
||||
}
|
||||
|
||||
async getAllLogs(): Promise<LogEntry[]> {
|
||||
return await this.getLogs();
|
||||
}
|
||||
|
||||
async clearLogs(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(this.storageKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async exportLogs(): Promise<string> {
|
||||
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 };
|
||||
Reference in New Issue
Block a user