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:
richarjiang
2025-09-11 19:11:09 +08:00
parent 35d6b74451
commit 3a4a55b78e
17 changed files with 1427 additions and 75 deletions

View File

@@ -17,6 +17,8 @@
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。", "NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。", "NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。", "NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。", "NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
"UIBackgroundModes": [ "UIBackgroundModes": [
"processing", "processing",
@@ -35,7 +37,8 @@
"permissions": [ "permissions": [
"android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE", "android.permission.VIBRATE",
"android.permission.WAKE_LOCK" "android.permission.WAKE_LOCK",
"android.permission.RECORD_AUDIO"
] ]
}, },
"web": { "web": {
@@ -66,10 +69,7 @@
"expo-notifications", "expo-notifications",
{ {
"icon": "./assets/images/Sealife.jpeg", "icon": "./assets/images/Sealife.jpeg",
"color": "#ffffff", "color": "#ffffff"
"sounds": [
"./assets/sounds/notification.wav"
]
} }
], ],
[ [

View File

@@ -1,17 +1,19 @@
import ActivityHeatMap from '@/components/ActivityHeatMap'; import ActivityHeatMap from '@/components/ActivityHeatMap';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; 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 { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -31,6 +33,11 @@ export default function PersonalScreen() {
const [notificationEnabled, setNotificationEnabled] = useState(false); const [notificationEnabled, setNotificationEnabled] = useState(false);
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
// 计算底部间距 // 计算底部间距
const bottomPadding = useMemo(() => { const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
@@ -86,6 +93,32 @@ export default function PersonalScreen() {
loadNotificationPreference(); 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) => { const handleNotificationToggle = async (value: boolean) => {
if (value) { if (value) {
@@ -148,7 +181,9 @@ export default function PersonalScreen() {
/> />
</View> </View>
<View style={styles.userDetails}> <View style={styles.userDetails}>
<Text style={styles.userName}>{displayName}</Text> <TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
</View> </View>
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}></Text> <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: '其他', title: '其他',
items: [ items: [

View File

@@ -13,7 +13,7 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
@@ -493,7 +493,7 @@ export default function ExploreScreen() {
style={styles.debugButton} style={styles.debugButton}
onPress={async () => { onPress={async () => {
console.log('🔧 手动触发后台任务测试...'); console.log('🔧 手动触发后台任务测试...');
await backgroundTaskManager.triggerTaskForTesting(); await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}} }}
> >
<Text style={styles.debugButtonText}>🔧</Text> <Text style={styles.debugButtonText}>🔧</Text>

View File

@@ -9,7 +9,6 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions'; import { useQuickActions } from '@/hooks/useQuickActions';
import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
import { notificationService } from '@/services/notifications'; import { notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions'; import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
@@ -24,8 +23,16 @@ import RNExitApp from 'react-native-exit-app';
import { DialogProvider } from '@/components/ui/DialogProvider'; import { DialogProvider } from '@/components/ui/DialogProvider';
import { ToastProvider } from '@/contexts/ToastContext'; import { ToastProvider } from '@/contexts/ToastContext';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { Provider } from 'react-redux'; 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 }) { function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { privacyAgreed, profile } = useAppSelector((state) => state.user); const { privacyAgreed, profile } = useAppSelector((state) => state.user);
@@ -42,14 +49,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
}; };
const initializeNotifications = async () => { const initializeNotifications = async () => {
try { try {
await BackgroundTaskManager.getInstance().initialize(promise);
// 初始化通知服务 // 初始化通知服务
await notificationService.initialize(); await notificationService.initialize();
console.log('通知服务初始化成功'); console.log('通知服务初始化成功');
// 初始化后台任务管理器
await backgroundTaskManager.initialize();
console.log('后台任务管理器初始化成功');
// 初始化快捷动作 // 初始化快捷动作
await setupQuickActions(); await setupQuickActions();
console.log('快捷动作初始化成功'); console.log('快捷动作初始化成功');
@@ -105,8 +110,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
const registerAllReminders = async () => { const registerAllReminders = async () => {
try { try {
await notificationService.initialize(); await notificationService.initialize();
// 后台任务
await backgroundTaskManager.initialize()
// 注册午餐提醒12:00 // 注册午餐提醒12:00
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || ''); await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
console.log('午餐提醒已注册'); console.log('午餐提醒已注册');

148
app/developer.tsx Normal file
View 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
View 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',
},
});

View File

@@ -8,7 +8,6 @@ import {
} from '@/utils/sleepHealthKit'; } from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
@@ -19,6 +18,7 @@ import {
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { ThemedView } from '@/components/ThemedView';
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal'; import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal'; import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
@@ -110,21 +110,13 @@ export default function SleepDetailScreen() {
}; };
return ( return (
<View style={styles.container}> <ThemedView style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f0f4ff', '#e6f2ff', '#ffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 顶部导航 */} {/* 顶部导航 */}
<HeaderBar <HeaderBar
title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`} title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
onBack={() => router.back()} onBack={() => router.back()}
withSafeTop={true}
transparent={true} transparent={true}
variant="default"
/> />
@@ -392,21 +384,13 @@ export default function SleepDetailScreen() {
visible={sleepStagesModal.visible} visible={sleepStagesModal.visible}
onClose={() => setSleepStagesModal({ visible: false })} onClose={() => setSleepStagesModal({ visible: false })}
/> />
</View> </ThemedView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F8FAFC',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,

471
app/voice-record.tsx Normal file
View 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',
},
});

View File

@@ -30,6 +30,11 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
router.push(`/food/camera?mealType=${mealType}`); router.push(`/food/camera?mealType=${mealType}`);
}; };
const handleVoiceRecord = () => {
onClose();
router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
};
const menuItems = [ const menuItems = [
{ {
id: 'scan', id: 'scan',
@@ -45,6 +50,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
backgroundColor: '#FF9500', backgroundColor: '#FF9500',
onPress: handleFoodLibrary, onPress: handleFoodLibrary,
}, },
{
id: 'voice-record',
title: '一句话记录',
icon: '🎤',
backgroundColor: '#7B68EE',
onPress: handleVoiceRecord,
},
]; ];
return ( return (

View File

@@ -37,6 +37,7 @@ export const ROUTES = {
// 营养相关路由 // 营养相关路由
NUTRITION_RECORDS: '/nutrition/records', NUTRITION_RECORDS: '/nutrition/records',
FOOD_LIBRARY: '/food-library', FOOD_LIBRARY: '/food-library',
VOICE_RECORD: '/voice-record',
// 体重记录相关路由 // 体重记录相关路由
WEIGHT_RECORDS: '/weight-records', WEIGHT_RECORDS: '/weight-records',
@@ -50,6 +51,10 @@ export const ROUTES = {
// 目标管理路由 (已移至tab中) // 目标管理路由 (已移至tab中)
// GOAL_MANAGEMENT: '/goal-management', // GOAL_MANAGEMENT: '/goal-management',
// 开发者相关路由
DEVELOPER: '/developer',
DEVELOPER_LOGS: '/developer/logs',
} as const; } as const;
// 路由参数常量 // 路由参数常量

View File

@@ -40,7 +40,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoAsset (11.1.7): - ExpoAsset (11.1.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoBackgroundTask (0.2.8): - ExpoBackgroundTask (1.0.6):
- ExpoModulesCore - ExpoModulesCore
- ExpoBlur (14.1.5): - ExpoBlur (14.1.5):
- ExpoModulesCore - ExpoModulesCore
@@ -104,7 +104,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoWebBrowser (14.2.0): - ExpoWebBrowser (14.2.0):
- ExpoModulesCore - ExpoModulesCore
- EXTaskManager (13.1.6): - EXTaskManager (14.0.6):
- ExpoModulesCore - ExpoModulesCore
- UMAppLoader - UMAppLoader
- fast_float (6.1.4) - fast_float (6.1.4)
@@ -1425,6 +1425,8 @@ PODS:
- React-Core - React-Core
- react-native-safe-area-context (5.4.0): - react-native-safe-area-context (5.4.0):
- React-Core - React-Core
- react-native-voice (3.2.4):
- React-Core
- react-native-webview (13.13.5): - react-native-webview (13.13.5):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -1987,7 +1989,7 @@ PODS:
- SDWebImage/Core (~> 5.17) - SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.53.2) - Sentry/HybridSDK (8.53.2)
- SocketRocket (0.7.1) - SocketRocket (0.7.1)
- UMAppLoader (5.1.3) - UMAppLoader (6.0.6)
- Yoga (0.0.0) - Yoga (0.0.0)
- ZXingObjC/Core (3.6.9) - ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (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-cos-sdk (from `../node_modules/react-native-cos-sdk`)
- react-native-render-html (from `../node_modules/react-native-render-html`) - 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-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-native-webview (from `../node_modules/react-native-webview`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
@@ -2266,6 +2269,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-render-html" :path: "../node_modules/react-native-render-html"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/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: react-native-webview:
:path: "../node_modules/react-native-webview" :path: "../node_modules/react-native-webview"
React-NativeModulesApple: React-NativeModulesApple:
@@ -2369,7 +2374,7 @@ SPEC CHECKSUMS:
Expo: c9e30ab79606b3800733594a961528bc4abb0ffe Expo: c9e30ab79606b3800733594a961528bc4abb0ffe
ExpoAppleAuthentication: 4d2e0c88a4463229760f1fbb9a937a810efb6863 ExpoAppleAuthentication: 4d2e0c88a4463229760f1fbb9a937a810efb6863
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6 ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
ExpoBackgroundTask: 6c1990438e45b5c4bbbc7d75aa6b688d53602fe8 ExpoBackgroundTask: f4dac8f09f3b187e464af7a1088d9fd5ae48a836
ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9 ExpoBlur: 3c8885b9bf9eef4309041ec87adec48b5f1986a9
ExpoCamera: e1879906d41184e84b57d7643119f8509414e318 ExpoCamera: e1879906d41184e84b57d7643119f8509414e318
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63 ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
@@ -2387,7 +2392,7 @@ SPEC CHECKSUMS:
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859 ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92 ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
EXTaskManager: 280143f6d8e596f28739d74bf34910300dcbd4ea EXTaskManager: eedcd03c1a574c47d3f48d83d4e4659b3c1fa29b
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52 FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
@@ -2435,6 +2440,7 @@ SPEC CHECKSUMS:
react-native-cos-sdk: a29ad87f60e2edb2adc46da634aa5b6e7cd14e35 react-native-cos-sdk: a29ad87f60e2edb2adc46da634aa5b6e7cd14e35
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1 react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954 react-native-webview: 3df1192782174d1bd23f6a0f5a4fec3cdcca9954
React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618 React-NativeModulesApple: 494c38599b82392ed14b2c0118fca162425bb618
React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd
@@ -2486,7 +2492,7 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
UMAppLoader: 55159b69750129faa7a51c493cb8ea55a7b64eb9 UMAppLoader: 2af2cc05fcaa9851233893c0e3dbc56a99f57e36
Yoga: adb397651e1c00672c12e9495babca70777e411e Yoga: adb397651e1c00672c12e9495babca70777e411e
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5

View File

@@ -63,6 +63,10 @@
<string>应用需要访问相册以选择您的体态照片用于AI测评。</string> <string>应用需要访问相册以选择您的体态照片用于AI测评。</string>
<key>NSUserNotificationsUsageDescription</key> <key>NSUserNotificationsUsageDescription</key>
<string>应用需要发送通知以提醒您喝水和站立活动。</string> <string>应用需要发送通知以提醒您喝水和站立活动。</string>
<key>NSMicrophoneUsageDescription</key>
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>

251
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "^2.11.1", "@react-native-picker/picker": "^2.11.1",
"@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
@@ -23,7 +24,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"expo": "53.0.22", "expo": "53.0.22",
"expo-apple-authentication": "~7.2.4", "expo-apple-authentication": "~7.2.4",
"expo-background-task": "~0.2.8", "expo-background-task": "~1.0.6",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-camera": "^16.1.11", "expo-camera": "^16.1.11",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
@@ -40,7 +41,7 @@
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5", "expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.11", "expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6", "expo-task-manager": "~14.0.6",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~14.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
@@ -2972,6 +2973,167 @@
"react-native": "*" "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": { "node_modules/@react-native/assets-registry": {
"version": "0.79.5", "version": "0.79.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", "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==", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT" "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": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -7114,12 +7285,12 @@
} }
}, },
"node_modules/expo-background-task": { "node_modules/expo-background-task": {
"version": "0.2.8", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-0.2.8.tgz", "resolved": "https://mirrors.tencent.com/npm/expo-background-task/-/expo-background-task-1.0.6.tgz",
"integrity": "sha512-dePyskpmyDZeOtbr9vWFh+Nrse0TvF6YitJqnKcd+3P7pDMiDr1V2aT6zHdNOc5iV9vPaDJoH/zdmlarp1uHMQ==", "integrity": "sha512-/lGXfpetLtFkAzfLyoVdagB9XMaMkXUgjRy/rLnZy5bXlTK3d7AxITzYs4ePQWRaJ89MPeOWokXg1cy3JgwOeA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"expo-task-manager": "~13.1.6" "expo-task-manager": "~14.0.6"
}, },
"peerDependencies": { "peerDependencies": {
"expo": "*" "expo": "*"
@@ -7456,12 +7627,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/expo-task-manager": { "node_modules/expo-task-manager": {
"version": "13.1.6", "version": "14.0.6",
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-13.1.6.tgz", "resolved": "https://mirrors.tencent.com/npm/expo-task-manager/-/expo-task-manager-14.0.6.tgz",
"integrity": "sha512-sYNAftpIeZ+j6ur17Jo0OpSTk9ks/MDvTbrNCimXMyjIt69XXYL/kAPYf76bWuxOuN8bcJ8Ef8YvihkwFG9hDA==", "integrity": "sha512-3JVLgnhD7P23wpZxBGmK+GHD2Wy57snAENAOymoVHURgk2EkdgEfbVl2lcsFtr6UyACF/6i/aIAKW3mWpUGx+A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"unimodules-app-loader": "~5.1.3" "unimodules-app-loader": "~6.0.6"
}, },
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -7799,6 +7970,21 @@
"node": ">= 0.6" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -9310,6 +9496,27 @@
"node": ">=6" "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": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -13892,9 +14099,9 @@
} }
}, },
"node_modules/unimodules-app-loader": { "node_modules/unimodules-app-loader": {
"version": "5.1.3", "version": "6.0.6",
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-5.1.3.tgz", "resolved": "https://mirrors.tencent.com/npm/unimodules-app-loader/-/unimodules-app-loader-6.0.6.tgz",
"integrity": "sha512-nPUkwfkpJWvdOQrVvyQSUol93/UdmsCVd9Hkx9RgAevmKSVYdZI+S87W73NGKl6QbwK9L1BDSY5OrQuo8Oq15g==", "integrity": "sha512-VpXX3H9QXP5qp1Xe0JcR2S/+i2VOuiOmyVi6q6S0hFFXcHBXoFYOcBnQDkqU/JXogZt3Wf/HSM0NGuiL3MhZIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unique-string": { "node_modules/unique-string": {
@@ -13909,6 +14116,15 @@
"node": ">=8" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -14447,6 +14663,15 @@
"node": ">=8.0" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz", "resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz",

View File

@@ -17,6 +17,7 @@
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "^2.11.1", "@react-native-picker/picker": "^2.11.1",
"@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
@@ -27,7 +28,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"expo": "53.0.22", "expo": "53.0.22",
"expo-apple-authentication": "~7.2.4", "expo-apple-authentication": "~7.2.4",
"expo-background-task": "~0.2.8", "expo-background-task": "~1.0.6",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-camera": "^16.1.11", "expo-camera": "^16.1.11",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
@@ -37,14 +38,14 @@
"expo-image-picker": "~16.1.4", "expo-image-picker": "~16.1.4",
"expo-linear-gradient": "^14.1.5", "expo-linear-gradient": "^14.1.5",
"expo-linking": "~7.1.7", "expo-linking": "~7.1.7",
"expo-notifications": "~0.31.4", "expo-notifications": "~0.32.10",
"expo-quick-actions": "^5.0.0", "expo-quick-actions": "^5.0.0",
"expo-router": "~5.1.5", "expo-router": "~5.1.5",
"expo-splash-screen": "~0.30.10", "expo-splash-screen": "~0.30.10",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5", "expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.11", "expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6", "expo-task-manager": "~14.0.6",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~14.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",

View File

@@ -1,11 +1,12 @@
import { store } from '@/store'; import { store } from '@/store';
import { log } from '@/utils/logger';
import { StandReminderHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { StandReminderHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as BackgroundTask from 'expo-background-task'; import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager'; import * as TaskManager from 'expo-task-manager';
import { TaskManagerTaskBody } 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) { if (this.isInitialized) {
console.log('后台任务管理器已初始化'); console.log('后台任务管理器已初始化');
return; return;
} }
try { try {
// 定义后台任务 // 定义后台任务
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => { TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
try { try {
console.log('[BackgroundTask] 后台任务执行'); log.info('[BackgroundTask] 后台任务执行');
await executeBackgroundTasks(); await executeBackgroundTasks();
return BackgroundTask.BackgroundTaskResult.Success; // return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) { } catch (error) {
console.error('[BackgroundTask] 任务执行失败:', 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, minimumInterval: 15,
}); });
console.log('[BackgroundTask] 配置状态:', status);
this.isInitialized = true; this.isInitialized = true;
console.log('后台任务管理器初始化完成'); log.info('后台任务管理器初始化完成');
} catch (error) { } catch (error) {
console.error('初始化后台任务管理器失败:', error); console.error('初始化后台任务管理器失败:', error);
@@ -299,11 +304,6 @@ export class BackgroundTaskManager {
} }
} }
/**
* 后台任务管理器单例实例
*/
export const backgroundTaskManager = BackgroundTaskManager.getInstance();
/** /**
* 后台任务事件类型 * 后台任务事件类型
*/ */

View File

@@ -54,3 +54,25 @@ export const triggerErrorHaptic = () => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); 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
View 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 };