diff --git a/.kilocode/rules/kilo-rule.md b/.kilocode/rules/kilo-rule.md index b62e813..4033e03 100644 --- a/.kilocode/rules/kilo-rule.md +++ b/.kilocode/rules/kilo-rule.md @@ -1,9 +1,10 @@ # kilo-rule.md -永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android +永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android, 代码设计优美、可读性高 ## 指导原则 - 遇到比较复杂的页面,尽量使用可以复用的组件 - 不要尝试使用 `npm run ios` 命令 +- 优先使用 Liquid Glass 风格组件 diff --git a/app/ai-posture-assessment.tsx b/app/ai-posture-assessment.tsx deleted file mode 100644 index 10f801c..0000000 --- a/app/ai-posture-assessment.tsx +++ /dev/null @@ -1,586 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; -import * as ImagePicker from 'expo-image-picker'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Image, - Linking, - Platform, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View -} from 'react-native'; -import ImageViewing from 'react-native-image-viewing'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; -import { useCosUpload } from '@/hooks/useCosUpload'; - -type PoseView = 'front' | 'side' | 'back'; - -type UploadState = { - front?: string | null; - side?: string | null; - back?: string | null; -}; - -type Sample = { uri: string; correct: boolean }; - -const SAMPLES: Record = { - front: [ - { uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true }, - { uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false }, - { uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false }, - ], - side: [ - { uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true }, - { uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false }, - { uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false }, - ], - back: [ - { uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true }, - { uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false }, - { uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false }, - ], -}; - -export default function AIPostureAssessmentScreen() { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const theme = Colors.light; - - const [uploadState, setUploadState] = useState({}); - const canStart = useMemo( - () => Boolean(uploadState.front && uploadState.side && uploadState.back), - [uploadState] - ); - - const { upload, uploading } = useCosUpload(); - const [uploadingKey, setUploadingKey] = useState(null); - - const [cameraPerm, setCameraPerm] = useState(null); - const [libraryPerm, setLibraryPerm] = useState(null); - const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null); - const [cameraCanAsk, setCameraCanAsk] = useState(null); - const [libraryCanAsk, setLibraryCanAsk] = useState(null); - - useEffect(() => { - (async () => { - const cam = await ImagePicker.getCameraPermissionsAsync(); - const lib = await ImagePicker.getMediaLibraryPermissionsAsync(); - setCameraPerm(cam.status); - setLibraryPerm(lib.status); - setLibraryAccess( - (lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none') - ); - setCameraCanAsk(cam.canAskAgain); - setLibraryCanAsk(lib.canAskAgain); - })(); - }, []); - - async function requestAllPermissions() { - try { - const cam = await ImagePicker.requestCameraPermissionsAsync(); - const lib = await ImagePicker.requestMediaLibraryPermissionsAsync(); - setCameraPerm(cam.status); - setLibraryPerm(lib.status); - setLibraryAccess( - (lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none') - ); - setCameraCanAsk(cam.canAskAgain); - setLibraryCanAsk(lib.canAskAgain); - const libGranted = lib.status === 'granted' || (lib as any).accessPrivileges === 'limited'; - if (cam.status !== 'granted' || !libGranted) { - Alert.alert( - '权限未完全授予', - '请在系统设置中授予相机与相册权限以完成上传', - [ - { text: '取消', style: 'cancel' }, - { text: '去设置', onPress: () => Linking.openSettings() }, - ] - ); - } - } catch { } - } - - async function requestPermissionAndPick(source: 'camera' | 'library', key: PoseView) { - try { - if (source === 'camera') { - const resp = await ImagePicker.requestCameraPermissionsAsync(); - setCameraPerm(resp.status); - setCameraCanAsk(resp.canAskAgain); - if (resp.status !== 'granted') { - Alert.alert( - '权限不足', - '需要相机权限以拍摄照片', - resp.canAskAgain - ? [{ text: '好的' }] - : [ - { text: '取消', style: 'cancel' }, - { text: '去设置', onPress: () => Linking.openSettings() }, - ] - ); - return; - } - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: true, - quality: 0.8, - aspect: [3, 4], - }); - if (!result.canceled) { - // 设置正在上传状态 - setUploadingKey(key); - try { - // 上传到 COS - const { url } = await upload( - { uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' }, - { prefix: 'posture-assessment/' } - ); - // 上传成功,更新状态 - setUploadState((s) => ({ ...s, [key]: url })); - } catch (uploadError) { - console.warn('上传图片失败', uploadError); - Alert.alert('上传失败', '图片上传失败,请重试'); - // 上传失败,清除状态 - setUploadState((s) => ({ ...s, [key]: null })); - } finally { - // 清除上传状态 - setUploadingKey(null); - } - } - } else { - const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); - setLibraryPerm(resp.status); - setLibraryAccess( - (resp as any).accessPrivileges ?? (resp.status === 'granted' ? 'all' : 'none') - ); - setLibraryCanAsk(resp.canAskAgain); - const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited'; - if (!libGranted) { - Alert.alert( - '权限不足', - '需要相册权限以选择照片', - resp.canAskAgain - ? [{ text: '好的' }] - : [ - { text: '取消', style: 'cancel' }, - { text: '去设置', onPress: () => Linking.openSettings() }, - ] - ); - return; - } - const result = await ImagePicker.launchImageLibraryAsync({ - allowsEditing: true, - quality: 0.8, - aspect: [3, 4], - }); - if (!result.canceled) { - // 设置正在上传状态 - setUploadingKey(key); - try { - // 上传到 COS - const { url } = await upload( - { uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' }, - { prefix: 'posture-assessment/' } - ); - // 上传成功,更新状态 - setUploadState((s) => ({ ...s, [key]: url })); - } catch (uploadError) { - console.warn('上传图片失败', uploadError); - Alert.alert('上传失败', '图片上传失败,请重试'); - // 上传失败,清除状态 - setUploadState((s) => ({ ...s, [key]: null })); - } finally { - // 清除上传状态 - setUploadingKey(null); - } - } - } - } catch (e) { - Alert.alert('发生错误', '选择图片失败,请重试'); - } - } - - function handleStart() { - if (!canStart) return; - // 进入评估中间页面 - router.push('/ai-posture-processing'); - } - - return ( - - router.back()} tone="light" transparent /> - - - {/* Permissions Banner (iOS 优先提示) */} - {Platform.OS === 'ios' && ( - (cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && ( - - 需要相机与相册权限 - - 授权后可拍摄或选择三视角全身照片用于AI体态测评。 - - - {((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? ( - - 一键授权 - - ) : ( - Linking.openSettings()}> - 去设置开启 - - )} - requestPermissionAndPick('library', 'front')}> - 稍后再说 - - - - ) - )} - - {/* Intro */} - - 上传标准姿势照片 - 请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。 - - - {/* Upload sections */} - requestPermissionAndPick('camera', 'front')} - onPickLibrary={() => requestPermissionAndPick('library', 'front')} - samples={SAMPLES.front} - uploading={uploading && uploadingKey === 'front'} - /> - - requestPermissionAndPick('camera', 'side')} - onPickLibrary={() => requestPermissionAndPick('library', 'side')} - samples={SAMPLES.side} - uploading={uploading && uploadingKey === 'side'} - /> - - requestPermissionAndPick('camera', 'back')} - onPickLibrary={() => requestPermissionAndPick('library', 'back')} - samples={SAMPLES.back} - uploading={uploading && uploadingKey === 'back'} - /> - - - {/* Bottom CTA */} - - - - {canStart ? '开始测评' : '请先完成三视角上传'} - - - - - ); -} - -function UploadTile({ - label, - value, - onPickCamera, - onPickLibrary, - samples, - uploading, -}: { - label: string; - value?: string | null; - onPickCamera: () => void; - onPickLibrary: () => void; - samples: Sample[]; - uploading?: boolean; -}) { - const [viewerVisible, setViewerVisible] = React.useState(false); - const [viewerIndex, setViewerIndex] = React.useState(0); - const imagesForViewer = React.useMemo(() => samples.map((s) => ({ uri: s.uri })), [samples]); - - return ( - - - {label} - {value ? ( - 可长按替换 - ) : ( - 需上传此视角 - )} - - - - {uploading ? ( - - - 上传中... - - ) : value ? ( - - ) : ( - - - - - 拍摄或选择照片 - 点击拍摄,长按从相册选择 - - )} - - - - 示例 - - {samples.map((s, idx) => ( - - { setViewerIndex(idx); setViewerVisible(true); }}> - - - - {s.correct ? '正确示范' : '错误示范'} - - - ))} - - - setViewerVisible(false)} - /> - - ); -} - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - permBanner: { - marginTop: 12, - marginHorizontal: 16, - padding: 14, - borderRadius: 16, - backgroundColor: 'rgba(25,33,38,0.06)' - }, - permTitle: { - color: '#192126', - fontSize: 16, - fontWeight: '700', - }, - permDesc: { - color: '#5E6468', - marginTop: 6, - fontSize: 13, - }, - permActions: { - flexDirection: 'row', - gap: 10, - marginTop: 10, - }, - permPrimary: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 14, - height: 40, - borderRadius: 12, - backgroundColor: Colors.light.accentGreen, - }, - permPrimaryText: { - color: '#192126', - fontSize: 14, - fontWeight: '800', - }, - permSecondary: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 14, - height: 40, - borderRadius: 12, - borderWidth: 1, - borderColor: 'rgba(25,33,38,0.14)', - }, - permSecondaryText: { - color: '#384046', - fontSize: 14, - fontWeight: '700', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - }, - backButton: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.06)', - }, - headerTitle: { - fontSize: 22, - color: '#ECEDEE', - fontWeight: '700', - }, - introBox: { - marginTop: 12, - paddingHorizontal: 20, - gap: 10, - }, - title: { - fontSize: 26, - color: '#ECEDEE', - fontWeight: '800', - }, - description: { - fontSize: 15, - lineHeight: 22, - color: 'rgba(255,255,255,0.75)', - }, - section: { - marginTop: 16, - paddingHorizontal: 16, - gap: 12, - }, - sectionHeader: { - paddingHorizontal: 4, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - sectionTitle: { - color: '#192126', - fontSize: 18, - fontWeight: '700', - }, - retakeHint: { - color: '#888F92', - fontSize: 13, - }, - uploader: { - height: 220, - borderRadius: 18, - borderWidth: 1, - borderStyle: 'dashed', - borderColor: 'rgba(25,33,38,0.14)', - backgroundColor: '#FFFFFF', - overflow: 'hidden', - }, - preview: { - width: '100%', - height: '100%', - }, - placeholder: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - plusBadge: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#FFFFFF', - borderWidth: 2, - borderColor: Colors.light.accentGreen, - }, - placeholderTitle: { - color: '#192126', - fontSize: 16, - fontWeight: '700', - }, - placeholderDesc: { - color: '#888F92', - fontSize: 12, - }, - sampleBox: { - marginTop: 8, - borderRadius: 16, - padding: 12, - backgroundColor: 'rgba(255,255,255,0.72)', - }, - sampleTitle: { - color: '#192126', - fontSize: 14, - marginBottom: 8, - fontWeight: '600', - }, - sampleRow: { - flexDirection: 'row', - gap: 10, - }, - sampleItem: { - flex: 1, - }, - sampleImg: { - width: '100%', - height: 90, - borderRadius: 12, - backgroundColor: '#F2F4F5', - }, - sampleTag: { - alignSelf: 'flex-start', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - marginTop: 6, - }, - sampleTagText: { - color: '#192126', - fontSize: 12, - fontWeight: '700', - }, - bottomCtaWrap: { - position: 'absolute', - left: 16, - right: 16, - bottom: 0, - }, - bottomCta: { - height: 64, - borderRadius: 32, - alignItems: 'center', - justifyContent: 'center', - }, - bottomCtaText: { - fontSize: 18, - fontWeight: '800', - }, -}); - diff --git a/app/ai-posture-processing.tsx b/app/ai-posture-processing.tsx deleted file mode 100644 index 661d905..0000000 --- a/app/ai-posture-processing.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useEffect } from 'react'; -import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; - -const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); - -export default function AIPostureProcessingScreen() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const theme = Colors.dark; - - // Core looping animations - const spin = useSharedValue(0); - const pulse = useSharedValue(0); - const scanY = useSharedValue(0); - const particle = useSharedValue(0); - - useEffect(() => { - spin.value = withRepeat(withTiming(1, { duration: 6000, easing: Easing.linear }), -1); - pulse.value = withRepeat(withSequence( - withTiming(1, { duration: 1600, easing: Easing.inOut(Easing.quad) }), - withTiming(0, { duration: 1600, easing: Easing.inOut(Easing.quad) }) - ), -1, true); - scanY.value = withRepeat(withTiming(1, { duration: 3800, easing: Easing.inOut(Easing.cubic) }), -1, false); - particle.value = withDelay(400, withRepeat(withTiming(1, { duration: 5200, easing: Easing.inOut(Easing.quad) }), -1, true)); - }, []); - - const ringStyleOuter = useAnimatedStyle(() => ({ - transform: [{ rotate: `${spin.value * 360}deg` }], - opacity: 0.8, - })); - const ringStyleInner = useAnimatedStyle(() => ({ - transform: [{ rotate: `${-spin.value * 360}deg` }, { scale: 0.98 + pulse.value * 0.04 }], - })); - const scannerStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: (scanY.value * (SCREEN_HEIGHT * 0.45)) - (SCREEN_HEIGHT * 0.225) }], - opacity: 0.6 + Math.sin(scanY.value * Math.PI) * 0.2, - })); - const particleStyleA = useAnimatedStyle(() => ({ - transform: [ - { translateX: Math.sin(particle.value * Math.PI * 2) * 40 }, - { translateY: Math.cos(particle.value * Math.PI * 2) * 24 }, - { rotate: `${particle.value * 360}deg` }, - ], - opacity: 0.5 + 0.5 * Math.abs(Math.sin(particle.value * Math.PI)), - })); - - return ( - - router.back()} tone="light" transparent /> - - {/* Layered background */} - - - - - - - {/* Hero visualization */} - - - - - - - {Array.from({ length: 9 }).map((_, i) => ( - - {Array.from({ length: 9 }).map((__, j) => ( - - ))} - - ))} - - - - - - - - {/* Copy & actions */} - - 正在进行体态特征提取与矢量评估 - 这通常需要 10-30 秒。你可以停留在此页面等待结果,或点击返回稍后在个人中心查看。 - - - router.replace('/ai-posture-result')} - > - - 保持页面等待 - - router.replace('/(tabs)/personal')}> - 返回个人中心 - - - - - ); -} - -const RING_SIZE = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.62; -const INNER_RING_SIZE = RING_SIZE * 0.72; - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - hero: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - blurBlobA: { - position: 'absolute', - top: -80, - right: -60, - width: 240, - height: 240, - borderRadius: 120, - backgroundColor: 'rgba(187,242,70,0.20)', - }, - blurBlobB: { - position: 'absolute', - bottom: 120, - left: -40, - width: 220, - height: 220, - borderRadius: 110, - backgroundColor: 'rgba(89, 198, 255, 0.16)', - }, - heroBackdrop: { - position: 'absolute', - width: RING_SIZE * 1.08, - height: RING_SIZE * 1.08, - borderRadius: (RING_SIZE * 1.08) / 2, - backgroundColor: 'rgba(25,33,38,0.25)', - }, - ringOuter: { - position: 'absolute', - width: RING_SIZE, - height: RING_SIZE, - borderRadius: RING_SIZE / 2, - borderWidth: 1, - borderColor: 'rgba(25,33,38,0.16)', - }, - ringInner: { - position: 'absolute', - width: INNER_RING_SIZE, - height: INNER_RING_SIZE, - borderRadius: INNER_RING_SIZE / 2, - borderWidth: 2, - borderColor: 'rgba(187,242,70,0.65)', - shadowColor: Colors.light.accentGreen, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.35, - shadowRadius: 24, - }, - grid: { - width: RING_SIZE * 0.9, - height: RING_SIZE * 0.9, - borderRadius: RING_SIZE * 0.45, - overflow: 'hidden', - padding: 10, - backgroundColor: 'rgba(25,33,38,0.08)', - }, - gridRow: { - flexDirection: 'row', - }, - gridCell: { - flex: 1, - aspectRatio: 1, - margin: 2, - borderRadius: 3, - backgroundColor: 'rgba(255,255,255,0.16)', - }, - scanner: { - position: 'absolute', - left: 0, - right: 0, - top: '50%', - height: 60, - marginTop: -30, - backgroundColor: 'rgba(187,242,70,0.10)', - borderWidth: 1, - borderColor: 'rgba(187,242,70,0.25)', - }, - particleA: { - position: 'absolute', - right: SCREEN_WIDTH * 0.18, - top: 40, - width: 14, - height: 14, - borderRadius: 7, - backgroundColor: Colors.light.accentGreen, - shadowColor: Colors.light.accentGreen, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.4, - shadowRadius: 16, - }, - particleB: { - position: 'absolute', - right: SCREEN_WIDTH * 0.08, - top: 120, - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: 'rgba(89, 198, 255, 1)', - shadowColor: 'rgba(89, 198, 255, 1)', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.4, - shadowRadius: 12, - }, - panel: { - paddingHorizontal: 20, - paddingTop: 8, - }, - title: { - color: '#ECEDEE', - fontSize: 18, - fontWeight: '800', - marginBottom: 8, - }, - subtitle: { - color: 'rgba(255,255,255,0.75)', - fontSize: 14, - lineHeight: 20, - }, - actions: { - flexDirection: 'row', - gap: 10, - marginTop: 14, - }, - primaryBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - height: 44, - paddingHorizontal: 16, - borderRadius: 12, - }, - primaryBtnText: { - color: '#192126', - fontSize: 14, - fontWeight: '800', - }, - secondaryBtn: { - flex: 1, - height: 44, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.18)', - }, - secondaryBtnText: { - color: 'rgba(255,255,255,0.85)', - fontSize: 14, - fontWeight: '700', - }, -}); - - diff --git a/app/ai-posture-result.tsx b/app/ai-posture-result.tsx deleted file mode 100644 index 372ef97..0000000 --- a/app/ai-posture-result.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; -import { useRouter } from 'expo-router'; -import React, { useMemo } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import Animated, { FadeInDown } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { RadarChart } from '@/components/RadarChart'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors } from '@/constants/Colors'; - -type PoseView = 'front' | 'side' | 'back'; - -// 斯多特普拉提体态评估维度(示例) -const DIMENSIONS = [ - { key: 'head_neck', label: '头颈对齐' }, - { key: 'shoulder', label: '肩带稳定' }, - { key: 'ribs', label: '胸廓控制' }, - { key: 'pelvis', label: '骨盆中立' }, - { key: 'spine', label: '脊柱排列' }, - { key: 'hip_knee', label: '髋膝对线' }, -]; - -type Issue = { - title: string; - severity: 'low' | 'medium' | 'high'; - description: string; - suggestions: string[]; -}; - -type ViewReport = { - score: number; // 0-5 - issues: Issue[]; -}; - -type ResultData = { - radar: number[]; // 与 DIMENSIONS 对应,0-5 - overview: string; - byView: Record; -}; - -// NOTE: 此处示例数据,后续可由 API 注入 -const MOCK_RESULT: ResultData = { - radar: [4.2, 3.6, 3.2, 4.6, 3.8, 3.4], - overview: '整体体态较为均衡,骨盆与脊柱控制较好;肩带稳定性与胸廓控制仍有提升空间。', - byView: { - front: { - score: 3.8, - issues: [ - { - title: '肩峰略前移,肩胛轻度外旋', - severity: 'medium', - description: '站立正面观察,右侧肩峰较左侧略有前移,提示肩带稳定性偏弱。', - suggestions: ['肩胛稳定训练(如天鹅摆臂分解)', '胸椎伸展与放松', '轻度弹力带外旋激活'], - }, - ], - }, - side: { - score: 4.1, - issues: [ - { - title: '骨盆接近中立,腰椎轻度前凸', - severity: 'low', - description: '侧面观察,骨盆位置接近中立位,腰椎存在轻度前凸,需注意腹压与肋骨下沉。', - suggestions: ['呼吸配合下的腹横肌激活', '猫牛流动改善胸椎灵活性'], - }, - ], - }, - back: { - score: 3.5, - issues: [ - { - title: '右侧肩胛轻度上抬', - severity: 'medium', - description: '背面观察,右肩胛较左侧轻度上抬,肩胛下回旋不足。', - suggestions: ['锯前肌激活训练', '低位划船,关注肩胛下沉与后缩'], - }, - ], - }, - }, -}; - -export default function AIPostureResultScreen() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const theme = Colors.light; - - const categories = useMemo(() => DIMENSIONS.map(d => ({ key: d.key, label: d.label })), []); - - const ScoreBadge = ({ score }: { score: number }) => ( - - {score.toFixed(1)} - /5 - - ); - - const IssueItem = ({ issue }: { issue: Issue }) => ( - - - - {issue.title} - {issue.description} - {!!issue.suggestions?.length && ( - - {issue.suggestions.map((s, idx) => ( - {s} - ))} - - )} - - - ); - - const ViewCard = ({ title, report }: { title: string; report: ViewReport }) => ( - - - {title} - - - {report.issues.map((iss, idx) => ())} - - ); - - return ( - - router.back()} tone="light" transparent /> - - {/* 背景装饰 */} - - - - - - - {/* 总览与雷达图 */} - - 总体概览 - {MOCK_RESULT.overview} - - - - - - {/* 视图分析 */} - - - - - {/* 底部操作 */} - - router.replace('/(tabs)/personal')}> - - 完成并返回 - - router.push('/(tabs)/coach')}> - 生成训练建议 - - - - - ); -} - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - bgBlobA: { - position: 'absolute', - top: -60, - right: -40, - width: 200, - height: 200, - borderRadius: 100, - backgroundColor: 'rgba(187,242,70,0.18)', - }, - bgBlobB: { - position: 'absolute', - bottom: 100, - left: -30, - width: 180, - height: 180, - borderRadius: 90, - backgroundColor: 'rgba(89, 198, 255, 0.16)', - }, - card: { - marginTop: 16, - marginHorizontal: 16, - borderRadius: 16, - padding: 14, - backgroundColor: 'rgba(255,255,255,0.72)', - borderWidth: 1, - borderColor: 'rgba(25,33,38,0.08)', - }, - sectionTitle: { - color: '#192126', - fontSize: 16, - fontWeight: '800', - marginBottom: 8, - }, - overview: { - color: '#384046', - fontSize: 14, - lineHeight: 20, - }, - radarWrap: { - marginTop: 10, - alignItems: 'center', - }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 8, - }, - cardTitle: { - color: '#192126', - fontSize: 15, - fontWeight: '700', - }, - scoreBadge: { - flexDirection: 'row', - alignItems: 'flex-end', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 10, - backgroundColor: 'rgba(187,242,70,0.16)', - }, - scoreText: { - color: '#192126', - fontSize: 18, - fontWeight: '800', - }, - scoreUnit: { - color: '#5E6468', - fontSize: 12, - marginLeft: 4, - }, - issueItem: { - flexDirection: 'row', - gap: 10, - paddingVertical: 10, - }, - issueDot: { - width: 10, - height: 10, - borderRadius: 5, - marginTop: 6, - }, - dotHigh: { backgroundColor: '#E24D4D' }, - dotMedium: { backgroundColor: '#F0C23C' }, - dotLow: { backgroundColor: '#2BCC7F' }, - issueTitle: { - color: '#192126', - fontSize: 14, - fontWeight: '700', - }, - issueDesc: { - color: '#5E6468', - fontSize: 13, - marginTop: 4, - }, - suggestRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginTop: 8, - }, - suggestChip: { - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 12, - backgroundColor: 'rgba(25,33,38,0.04)', - borderWidth: 1, - borderColor: 'rgba(25,33,38,0.08)', - }, - suggestText: { - color: '#192126', - fontSize: 12, - }, - actions: { - marginTop: 16, - paddingHorizontal: 16, - flexDirection: 'row', - gap: 10, - }, - primaryBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - height: 48, - paddingHorizontal: 16, - borderRadius: 14, - }, - primaryBtnText: { - color: '#192126', - fontSize: 15, - fontWeight: '800', - }, - secondaryBtn: { - flex: 1, - height: 48, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: 'transparent', - }, - secondaryBtnText: { - color: '#384046', - fontSize: 15, - fontWeight: '700', - }, -}); - - diff --git a/app/basal-metabolism-detail.tsx b/app/basal-metabolism-detail.tsx index 124e6e5..ef72c6a 100644 --- a/app/basal-metabolism-detail.tsx +++ b/app/basal-metabolism-detail.tsx @@ -2,6 +2,7 @@ import { DateSelector } from '@/components/DateSelector'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppSelector } from '@/hooks/redux'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { selectUserAge, selectUserProfile } from '@/store/userSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { fetchBasalEnergyBurned } from '@/utils/health'; @@ -25,6 +26,7 @@ type BasalMetabolismData = { export default function BasalMetabolismDetailScreen() { const userProfile = useAppSelector(selectUserProfile); const userAge = useAppSelector(selectUserAge); + const safeAreaTop = useSafeAreaTop() // 日期相关状态 const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); @@ -329,11 +331,14 @@ export default function BasalMetabolismDetailScreen() { } /> + + diff --git a/app/challenges/[id]/index.tsx b/app/challenges/[id]/index.tsx index 686261c..6740f0d 100644 --- a/app/challenges/[id]/index.tsx +++ b/app/challenges/[id]/index.tsx @@ -362,7 +362,6 @@ export default function ChallengeDetailScreen() { (); const router = useRouter(); const dispatch = useAppDispatch(); @@ -74,6 +76,9 @@ export default function ChallengeLeaderboardScreen() { return ( router.back()} withSafeTop /> + 未找到该挑战。 @@ -144,7 +149,7 @@ export default function ChallengeLeaderboardScreen() { router.back()} withSafeTop /> diff --git a/app/fitness-rings-detail.tsx b/app/fitness-rings-detail.tsx index 375cb1a..2b47e4f 100644 --- a/app/fitness-rings-detail.tsx +++ b/app/fitness-rings-detail.tsx @@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { fetchActivityRingsForDate, fetchHourlyActiveCaloriesForDate, @@ -50,6 +51,7 @@ type WeekData = { }; export default function FitnessRingsDetailScreen() { + const safeAreaTop = useSafeAreaTop() const colorScheme = useColorScheme(); const [weekData, setWeekData] = useState([]); const [selectedDate, setSelectedDate] = useState(new Date()); @@ -511,7 +513,9 @@ export default function FitnessRingsDetailScreen() { {/* 本周圆环横向滚动 */} diff --git a/app/food-library.tsx b/app/food-library.tsx index c3781f8..81b46fa 100644 --- a/app/food-library.tsx +++ b/app/food-library.tsx @@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors'; import { DEFAULT_IMAGE_FOOD } from '@/constants/Image'; import { useAppDispatch } from '@/hooks/redux'; import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords'; import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi'; import { fetchDailyNutritionData } from '@/store/nutritionSlice'; @@ -36,6 +37,7 @@ const MEAL_TYPE_MAP = { }; export default function FoodLibraryScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const params = useLocalSearchParams<{ mealType?: string }>(); const mealType = (params.mealType as MealType) || 'breakfast'; @@ -272,7 +274,6 @@ export default function FoodLibraryScreen() { router.back()} - transparent={false} variant="elevated" right={ @@ -281,6 +282,10 @@ export default function FoodLibraryScreen() { } /> + + {/* 搜索框 */} diff --git a/app/food/analysis-result.tsx b/app/food/analysis-result.tsx index 6b7e884..679759e 100644 --- a/app/food/analysis-result.tsx +++ b/app/food/analysis-result.tsx @@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { ROUTES } from '@/constants/Routes'; import { useAppSelector } from '@/hooks/redux'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords'; import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice'; import { Ionicons } from '@expo/vector-icons'; @@ -73,6 +74,7 @@ const MEAL_TYPE_MAP = { }; export default function FoodAnalysisResultScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const params = useLocalSearchParams<{ imageUri?: string; @@ -264,6 +266,9 @@ export default function FoodAnalysisResultScreen() { title="分析结果" onBack={() => router.back()} /> + 未找到图片或识别结果 @@ -287,7 +292,9 @@ export default function FoodAnalysisResultScreen() { transparent={true} /> - + {/* 食物主图 */} {imageUri ? ( diff --git a/app/food/camera.tsx b/app/food/camera.tsx index b0fe2dc..3b52b22 100644 --- a/app/food/camera.tsx +++ b/app/food/camera.tsx @@ -1,5 +1,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { Ionicons } from '@expo/vector-icons'; import { CameraType, CameraView, useCameraPermissions } from 'expo-camera'; import { Image } from 'expo-image'; @@ -8,21 +9,18 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useRef, useState } from 'react'; import { Alert, - Dimensions, Modal, StatusBar, StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; -const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); - export default function FoodCameraScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const params = useLocalSearchParams<{ mealType?: string }>(); const cameraRef = useRef(null); @@ -45,28 +43,34 @@ export default function FoodCameraScreen() { if (!permission) { // 权限仍在加载中 return ( - + router.back()} transparent={true} /> + 正在加载相机... - + ); } if (!permission.granted) { // 没有相机权限 return ( - + router.back()} backColor='#ffffff' /> + 需要相机权限 @@ -80,7 +84,7 @@ export default function FoodCameraScreen() { 授权访问 - + ); } @@ -152,7 +156,9 @@ export default function FoodCameraScreen() { transparent={true} backColor={'#fff'} /> - + {/* 主要内容区域 */} {/* 取景框容器 */} diff --git a/app/food/food-recognition.tsx b/app/food/food-recognition.tsx index 7046ed6..b9cc8c1 100644 --- a/app/food/food-recognition.tsx +++ b/app/food/food-recognition.tsx @@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useCosUpload } from '@/hooks/useCosUpload'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { recognizeFood } from '@/services/foodRecognition'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { Ionicons } from '@expo/vector-icons'; @@ -22,6 +23,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; export default function FoodRecognitionScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const params = useLocalSearchParams<{ imageUri?: string; @@ -217,6 +219,9 @@ export default function FoodRecognitionScreen() { title="食物识别" onBack={() => router.back()} /> + 未找到图片 @@ -232,7 +237,9 @@ export default function FoodRecognitionScreen() { /> {/* 主要内容区域 */} - + {!showRecognitionProcess ? ( // 确认界面 <> diff --git a/app/mood-statistics.tsx b/app/mood-statistics.tsx deleted file mode 100644 index 9eeb27a..0000000 --- a/app/mood-statistics.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { MoodHistoryCard } from '@/components/MoodHistoryCard'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { - fetchMoodHistory, - fetchMoodStatistics, - selectMoodLoading, - selectMoodRecords, - selectMoodStatistics -} from '@/store/moodSlice'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import dayjs from 'dayjs'; -import { LinearGradient } from 'expo-linear-gradient'; -import { router } from 'expo-router'; -import React, { useEffect } from 'react'; -import { - ActivityIndicator, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - View, -} from 'react-native'; - -export default function MoodStatisticsScreen() { - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - const { isLoggedIn } = useAuthGuard(); - const dispatch = useAppDispatch(); - - // 从 Redux 获取数据 - const moodRecords = useAppSelector(selectMoodRecords); - const statistics = useAppSelector(selectMoodStatistics); - const loading = useAppSelector(selectMoodLoading); - - // 获取最近30天的心情数据 - const loadMoodData = async () => { - if (!isLoggedIn) return; - - try { - const endDate = dayjs().format('YYYY-MM-DD'); - const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD'); - - // 并行加载历史记录和统计数据 - await Promise.all([ - dispatch(fetchMoodHistory({ startDate, endDate })), - dispatch(fetchMoodStatistics({ startDate, endDate })) - ]); - } catch (error) { - console.error('加载心情数据失败:', error); - } - }; - - useEffect(() => { - loadMoodData(); - }, [isLoggedIn, dispatch]); - - // 将 moodRecords 转换为数组格式 - const moodCheckins = Object.values(moodRecords).flat(); - - // 使用统一的渐变背景色 - const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; - - if (!isLoggedIn) { - return ( - - - - - 请先登录查看心情统计 - - - - ); - } - - return ( - - - - router.back()} - withSafeTop={false} - transparent={true} - tone="light" - /> - - - {loading.history || loading.statistics ? ( - - - 加载中... - - ) : ( - <> - {/* 统计概览 */} - {statistics && ( - - 统计概览 - - - {statistics.totalCheckins} - 总打卡次数 - - - {statistics.averageIntensity.toFixed(1)} - 平均强度 - - - - {statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0} - - 最常见心情 - - - - )} - - {/* 心情历史记录 */} - - - {/* 心情分布 */} - {statistics && ( - - 心情分布 - - {Object.entries(statistics.moodDistribution) - .sort(([, a], [, b]) => b - a) - .map(([moodType, count]) => ( - - {moodType} - - {count} - - ({((count / statistics.totalCheckins) * 100).toFixed(1)}%) - - - - ))} - - - )} - - )} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - gradientBackground: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - safeArea: { - flex: 1, - }, - scrollView: { - flex: 1, - paddingHorizontal: 20, - }, - centerContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loginPrompt: { - fontSize: 16, - color: '#666', - textAlign: 'center', - }, - - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 60, - }, - loadingText: { - fontSize: 16, - color: '#666', - marginTop: 16, - }, - sectionTitle: { - fontSize: 20, - fontWeight: '700', - color: '#192126', - marginBottom: 16, - }, - statsOverview: { - marginBottom: 24, - }, - statsGrid: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: 12, - }, - statCard: { - flex: 1, - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 20, - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3.84, - elevation: 5, - }, - statNumber: { - fontSize: 24, - fontWeight: '800', - color: '#192126', - marginBottom: 8, - }, - statLabel: { - fontSize: 14, - color: '#6B7280', - textAlign: 'center', - }, - distributionContainer: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 24, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3.84, - elevation: 5, - }, - distributionList: { - gap: 12, - }, - distributionItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 8, - }, - moodType: { - fontSize: 16, - fontWeight: '500', - color: '#192126', - }, - countContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - count: { - fontSize: 16, - fontWeight: '600', - color: '#192126', - }, - percentage: { - fontSize: 14, - color: '#6B7280', - }, -}); diff --git a/app/mood/calendar.tsx b/app/mood/calendar.tsx index 5be6ae8..6449a98 100644 --- a/app/mood/calendar.tsx +++ b/app/mood/calendar.tsx @@ -1,6 +1,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { useAppSelector } from '@/hooks/redux'; import { useMoodData } from '@/hooks/useMoodData'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getMoodOptions } from '@/services/moodCheckins'; import { selectLatestMoodRecordByDate } from '@/store/moodSlice'; import dayjs from 'dayjs'; @@ -8,7 +9,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Dimensions, Image, SafeAreaView, + Dimensions, Image, ScrollView, StyleSheet, Text, @@ -60,6 +61,7 @@ const generateCalendarData = (targetDate: Date) => { }; export default function MoodCalendarScreen() { + const safeAreaTop = useSafeAreaTop() const params = useLocalSearchParams(); const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData(); @@ -231,7 +233,7 @@ export default function MoodCalendarScreen() { - + router.back()} @@ -240,7 +242,9 @@ export default function MoodCalendarScreen() { tone="light" /> - + {/* 日历视图 */} {/* 月份导航 */} @@ -363,7 +367,7 @@ export default function MoodCalendarScreen() { )} - + ); } diff --git a/app/mood/edit.tsx b/app/mood/edit.tsx index ec573da..fee65c1 100644 --- a/app/mood/edit.tsx +++ b/app/mood/edit.tsx @@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getMoodOptions, MoodType } from '@/services/moodCheckins'; import { createMoodRecord, @@ -28,9 +29,10 @@ import { TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; export default function MoodEditScreen() { + const safeAreaTop = useSafeAreaTop() + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const params = useLocalSearchParams(); @@ -179,7 +181,7 @@ export default function MoodEditScreen() { {/* 装饰性圆圈 */} - + router.back()} @@ -196,81 +198,83 @@ export default function MoodEditScreen() { - {/* 日期显示 */} - - - {dayjs(selectedDate).format('YYYY年M月D日')} - - - - {/* 心情选择 */} - - 选择心情 - - {moodOptions.map((mood, index) => ( - setSelectedMood(mood.type)} - > - - {mood.label} - - ))} + {/* 日期显示 */} + + + {dayjs(selectedDate).format('YYYY年M月D日')} + - - {/* 心情强度选择 */} - - 心情强度 - - + {/* 心情选择 */} + + 选择心情 + + {moodOptions.map((mood, index) => ( + setSelectedMood(mood.type)} + > + + {mood.label} + + ))} + + + + {/* 心情强度选择 */} + + 心情强度 + + - {/* 心情描述 */} + {/* 心情描述 */} - - 心情日记 - 记录你的心情,珍藏美好回忆 - + 心情日记 + 记录你的心情,珍藏美好回忆 + { - // 当文本输入框获得焦点时,滚动到输入框 - setTimeout(() => { - scrollViewRef.current?.scrollToEnd({ animated: true }); - }, 300); - }} - /> - {description.length}/1000 - + placeholderTextColor="#a8a8a8" + value={description} + onChangeText={setDescription} + multiline + maxLength={1000} + textAlignVertical="top" + onFocus={() => { + // 当文本输入框获得焦点时,滚动到输入框 + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 300); + }} + /> + {description.length}/1000 + @@ -299,7 +303,7 @@ export default function MoodEditScreen() { )} - + ); } diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 3a22176..4dcc7b6 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -6,6 +6,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { DietRecord } from '@/services/dietRecords'; import { type FoodRecognitionResponse } from '@/services/foodRecognition'; import { saveRecognitionResult } from '@/store/foodRecognitionSlice'; @@ -37,6 +38,7 @@ import { type ViewMode = 'daily' | 'all'; export default function NutritionRecordsScreen() { + const safeAreaTop = useSafeAreaTop() const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const dispatch = useAppDispatch(); @@ -425,46 +427,52 @@ export default function NutritionRecordsScreen() { right={renderRightButton()} /> - {/* {renderViewModeToggle()} */} - {renderDateSelector()} + - {/* Calorie Ring Chart */} - + {/* {renderViewModeToggle()} */} + {renderDateSelector()} - {( - renderRecord({ item, index })} - keyExtractor={(item) => item.id.toString()} - contentContainerStyle={[ - styles.listContainer, - { paddingBottom: 40, paddingTop: 16 } - ]} - showsVerticalScrollIndicator={false} - refreshControl={ - - } - ListEmptyComponent={renderEmptyState} - ListFooterComponent={renderFooter} - onEndReached={viewMode === 'all' ? loadMoreRecords : undefined} - onEndReachedThreshold={0.1} + {/* Calorie Ring Chart */} + - )} + + {( + renderRecord({ item, index })} + keyExtractor={(item) => item.id.toString()} + contentContainerStyle={[ + styles.listContainer, + { paddingBottom: 40, paddingTop: 16 } + ]} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={renderEmptyState} + ListFooterComponent={renderFooter} + onEndReached={viewMode === 'all' ? loadMoreRecords : undefined} + onEndReachedThreshold={0.1} + /> + )} + + {/* 食物添加悬浮窗 */} - - - {/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */} + router.back()} @@ -300,7 +297,7 @@ export default function EditProfileScreen() { /> - + {/* 头像(带相机蒙层,点击从相册选择) */} @@ -504,7 +501,7 @@ export default function EditProfileScreen() { - + ); } diff --git a/app/profile/goals.tsx b/app/profile/goals.tsx index 4af37ce..351267d 100644 --- a/app/profile/goals.tsx +++ b/app/profile/goals.tsx @@ -6,17 +6,17 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import React, { useEffect, useMemo, useState } from 'react'; import { - SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, - View, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ProgressBar } from '@/components/ProgressBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { updateUser as updateUserApi } from '@/services/users'; import { fetchMyProfile } from '@/store/userSlice'; import { useFocusEffect } from '@react-navigation/native'; @@ -43,6 +43,7 @@ function arraysEqualUnordered(a?: string[], b?: string[]): boolean { } export default function GoalsScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const insets = useSafeAreaInsets(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; @@ -135,7 +136,7 @@ export default function GoalsScreen() { lastSentRef.current.calories = calories; (async () => { try { - await updateUserApi({ userId, dailyCaloriesGoal: calories }); + await updateUserApi({ dailyCaloriesGoal: calories }); await dispatch(fetchMyProfile() as any); } catch { } })(); @@ -148,7 +149,7 @@ export default function GoalsScreen() { lastSentRef.current.steps = steps; (async () => { try { - await updateUserApi({ userId, dailyStepsGoal: steps }); + await updateUserApi({ dailyStepsGoal: steps }); await dispatch(fetchMyProfile() as any); } catch { } })(); @@ -161,7 +162,7 @@ export default function GoalsScreen() { lastSentRef.current.purposes = [...purposes]; (async () => { try { - await updateUserApi({ userId, pilatesPurposes: purposes }); + await updateUserApi({ pilatesPurposes: purposes }); await dispatch(fetchMyProfile() as any); } catch { } })(); @@ -245,9 +246,9 @@ export default function GoalsScreen() { return ( - + router.back()} withSafeTop={false} tone={theme} transparent /> - @@ -305,7 +306,7 @@ export default function GoalsScreen() { - + ); } diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx index efa73cf..d3e2ab7 100644 --- a/app/sleep-detail.tsx +++ b/app/sleep-detail.tsx @@ -26,6 +26,7 @@ import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; // SleepGradeCard 组件现在在 InfoModal 组件内部 @@ -34,6 +35,8 @@ import { useColorScheme } from '@/hooks/useColorScheme'; // InfoModal 组件现在从独立文件导入 export default function SleepDetailScreen() { + const safeAreaTop = useSafeAreaTop() + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const [sleepData, setSleepData] = useState(null); @@ -123,7 +126,9 @@ export default function SleepDetailScreen() { {/* 睡眠得分圆形显示 */} diff --git a/app/steps/detail.tsx b/app/steps/detail.tsx index 153aa63..7cbb7e4 100644 --- a/app/steps/detail.tsx +++ b/app/steps/detail.tsx @@ -1,5 +1,6 @@ import { DateSelector } from '@/components/DateSelector'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { logger } from '@/utils/logger'; @@ -16,6 +17,8 @@ import { } from 'react-native'; export default function StepsDetailScreen() { + const safeAreaTop = useSafeAreaTop() + // 获取路由参数 const { date } = useLocalSearchParams<{ date?: string }>(); @@ -213,7 +216,9 @@ export default function StepsDetailScreen() { {/* 日期选择器 */} diff --git a/app/task-detail.tsx b/app/task-detail.tsx index a4ec1e8..944d62d 100644 --- a/app/task-detail.tsx +++ b/app/task-detail.tsx @@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { completeTask, skipTask } from '@/store/tasksSlice'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -19,17 +20,18 @@ import { } from 'react-native'; export default function TaskDetailScreen() { + const safeAreaTop = useSafeAreaTop() const { taskId } = useLocalSearchParams<{ taskId: string }>(); const router = useRouter(); const theme = useColorScheme() ?? 'light'; const colorTokens = Colors[theme]; const dispatch = useAppDispatch(); const { showConfirm } = useGlobalDialog(); - + // 从Redux中获取任务数据 const { tasks, tasksLoading } = useAppSelector(state => state.tasks); const task = tasks.find(t => t.id === taskId) || null; - + const [comment, setComment] = useState(''); const getStatusText = (status: string) => { @@ -98,10 +100,10 @@ export default function TaskDetailScreen() { const formatDate = (dateString: string) => { const date = new Date(dateString); - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'long', - day: 'numeric' + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric' }; return `创建于 ${date.toLocaleDateString('zh-CN', options)}`; }; @@ -112,14 +114,14 @@ export default function TaskDetailScreen() { } try { - await dispatch(completeTask({ - taskId: task.id, - completionData: { + await dispatch(completeTask({ + taskId: task.id, + completionData: { count: 1, notes: '通过任务详情页面完成' - } + } })).unwrap(); - + // 检查任务是否真正完成(当前完成次数是否达到目标次数) const updatedTask = tasks.find(t => t.id === task.id); if (updatedTask && updatedTask.currentCount >= updatedTask.targetCount) { @@ -150,13 +152,13 @@ export default function TaskDetailScreen() { }, async () => { try { - await dispatch(skipTask({ - taskId: task.id, - skipData: { + await dispatch(skipTask({ + taskId: task.id, + skipData: { reason: '用户主动跳过' - } + } })).unwrap(); - + Alert.alert('成功', '任务已跳过!'); router.back(); } catch (error) { @@ -178,10 +180,13 @@ export default function TaskDetailScreen() { if (tasksLoading) { return ( - router.back()} /> + 加载中... @@ -192,10 +197,13 @@ export default function TaskDetailScreen() { if (!task) { return ( - router.back()} /> + 任务不存在 @@ -206,8 +214,8 @@ export default function TaskDetailScreen() { return ( {/* 使用HeaderBar组件 */} - router.back()} right={ task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount ? ( @@ -222,7 +230,9 @@ export default function TaskDetailScreen() { } /> - + {/* 任务标题和创建时间 */} {task.title} @@ -255,7 +265,7 @@ export default function TaskDetailScreen() { - + 难度 @@ -268,7 +278,7 @@ export default function TaskDetailScreen() { {/* 任务进度信息 */} 进度 - + {/* 进度条 */} 0 ? `${Math.min(task.progressPercentage, 100)}%` : '2%', - backgroundColor: task.progressPercentage >= 100 - ? '#10B981' - : task.progressPercentage >= 50 - ? '#F59E0B' - : task.progressPercentage > 0 - ? colorTokens.primary - : '#E5E7EB', + backgroundColor: task.progressPercentage >= 100 + ? '#10B981' + : task.progressPercentage >= 50 + ? '#F59E0B' + : task.progressPercentage > 0 + ? colorTokens.primary + : '#E5E7EB', }, ]} /> @@ -315,8 +325,8 @@ export default function TaskDetailScreen() { {/* 底部操作按钮 */} {task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && ( - @@ -338,7 +348,7 @@ export default function TaskDetailScreen() { - { const aCompleted = a.status === 'completed'; const bCompleted = b.status === 'completed'; - + // 如果a已完成而b未完成,a排在后面 if (aCompleted && !bCompleted) { return 1; @@ -133,7 +134,7 @@ export default function GoalsDetailScreen() { // 渲染空状态 const renderEmptyState = () => { const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日'); - + if (tasksLoading) { return ( @@ -143,7 +144,7 @@ export default function GoalsDetailScreen() { ); } - + return ( @@ -157,12 +158,15 @@ export default function GoalsDetailScreen() { }; return ( - - + + {/* 标题区域 */} + {/* 背景渐变 */} + + + - {/* 标题区域 */} - + {/* 日期选择器 */} @@ -214,7 +217,7 @@ export default function GoalsDetailScreen() { /> - + ); } diff --git a/app/training-plan.tsx b/app/training-plan.tsx deleted file mode 100644 index 7a844db..0000000 --- a/app/training-plan.tsx +++ /dev/null @@ -1,1425 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import MaskedView from '@react-native-masked-view/masked-view'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, FlatList, Modal, Pressable, SafeAreaView, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native'; -import Animated, { - FadeInUp, - FadeOut, - interpolate, - Layout, - useAnimatedStyle, - useSharedValue, - withRepeat, - withSpring, - withTiming -} from 'react-native-reanimated'; - -import { ThemedText } from '@/components/ThemedText'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { Colors, palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { TrainingPlan } from '@/services/trainingPlanApi'; -import { - addExercise, - clearExercises, - clearError as clearScheduleError, - deleteExercise, - loadExercises, - toggleCompletion -} from '@/store/scheduleExerciseSlice'; -import { activatePlan, clearError, deletePlan, loadPlans } from '@/store/trainingPlanSlice'; -import { buildClassicalSession } from '@/utils/classicalSession'; - -// Tab 类型定义 -type TabType = 'list' | 'schedule'; - -// ScheduleItemType 类型定义 -type ScheduleItemType = 'exercise' | 'rest' | 'note'; - - -const GOAL_TEXT: Record = { - postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, - fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, - posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, - core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, - flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, - rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, - stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, -}; - -// 动态背景组件 -function DynamicBackground() { - const rotate = useSharedValue(0); - const scale = useSharedValue(1); - - React.useEffect(() => { - rotate.value = withRepeat(withTiming(360, { duration: 20000 }), -1); - scale.value = withRepeat(withTiming(1.2, { duration: 8000 }), -1, true); - }, []); - - const backgroundStyle = useAnimatedStyle(() => ({ - transform: [ - { rotate: `${rotate.value}deg` }, - { scale: scale.value } - ], - })); - - return ( - - - - - - ); -} - - -// 渐变文字 -function GradientText({ children }: { children: string }) { - return ( - {children}}> - - {children} - - - ); -} - -// 新视觉训练计划卡片 -function PlanCard({ plan, onPress, onDelete, onActivate, onSchedule, isActive, index }: { plan: TrainingPlan; onPress: () => void; onDelete: () => void; onActivate: () => void; onSchedule: () => void; isActive?: boolean; index: number }) { - const scale = useSharedValue(1); - const glow = useSharedValue(0); - - React.useEffect(() => { - glow.value = withRepeat(withTiming(1, { duration: 2000 + index * 100 }), -1, true); - }, [index]); - - const goalConfig = GOAL_TEXT[plan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; - - const cardStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - const glowStyle = useAnimatedStyle(() => { - const opacity = isActive ? interpolate(glow.value, [0, 1], [0.25, 0.55]) : interpolate(glow.value, [0, 1], [0.1, 0.3]); - return { - shadowOpacity: opacity, - shadowColor: '#000', - shadowRadius: isActive ? 28 : 18, - elevation: isActive ? 18 : 10, - borderColor: isActive ? `${goalConfig.color}55` : '#1B262B', - }; - }); - - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - return `${date.getMonth() + 1}月${date.getDate()}日`; - }; - - const getFrequencyText = () => { - if (plan.mode === 'daysOfWeek') { - return `每周${plan.daysOfWeek.length}天`; - } - return `每周${plan.sessionsPerWeek}次`; - }; - - const displayTitle = plan.name?.trim() ? plan.name : goalConfig.title; - const frequencyCount = plan.mode === 'daysOfWeek' ? plan.daysOfWeek.length : plan.sessionsPerWeek; - const sinceCreatedDays = Math.max(0, Math.floor((Date.now() - new Date(plan.createdAt).getTime()) / (24 * 3600 * 1000))); - const startDeltaDays = Math.floor((Date.now() - new Date(plan.startDate).getTime()) / (24 * 3600 * 1000)); - - return ( - - { - Alert.alert('操作', '选择要执行的操作', [ - { text: '排课', onPress: onSchedule }, - { text: isActive ? '已激活' : '激活', onPress: onActivate }, - { text: '删除', style: 'destructive', onPress: onDelete }, - { text: '取消', style: 'cancel' }, - ]); - }} - onPressIn={() => { scale.value = withSpring(0.98); }} - onPressOut={() => { scale.value = withSpring(1); }} - style={styles.darkCard} - > - - {displayTitle} - - {`${goalConfig.description} · 开始于 ${formatDate(plan.startDate)} · ${getFrequencyText()}${plan.preferredTimeOfDay ? ` · ${plan.preferredTimeOfDay === 'morning' ? '晨练' : plan.preferredTimeOfDay === 'noon' ? '午间' : '晚间'}` : ''}`} - - - - - - 排课 - - - - {isActive ? '已激活' : '激活'} - - { - Alert.alert('确认删除', '确定要删除这个训练计划吗?此操作无法撤销。', [ - { text: '取消', style: 'cancel' }, - { text: '删除', style: 'destructive', onPress: onDelete }, - ]); - }} hitSlop={8}> - - 删除 - - - - - ); -} - -// 底部 Tab 组件 -function BottomTabs({ activeTab, onTabChange, selectedPlan }: { - activeTab: TabType; - onTabChange: (tab: TabType) => void; - selectedPlan?: TrainingPlan; -}) { - const theme = useColorScheme() ?? 'light'; - const colorTokens = Colors[theme]; - - return ( - - - onTabChange('list')} - > - - {activeTab === 'list' && ( - 训练计划 - )} - - - onTabChange('schedule')} - > - - {activeTab === 'schedule' && ( - 锻炼排期 - )} - - - - ); -} - -export default function TrainingPlanScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const params = useLocalSearchParams<{ planId?: string; tab?: string }>(); - const { plans, loading, error } = useAppSelector((s) => s.trainingPlan); - const { exercises, error: scheduleError } = useAppSelector((s) => s.scheduleExercise); - - console.log('plans', plans); - // Tab 状态管理 - 支持从URL参数设置初始tab - const initialTab: TabType = params.tab === 'schedule' ? 'schedule' : 'list'; - const [activeTab, setActiveTab] = useState(initialTab); - const [selectedPlanId, setSelectedPlanId] = useState(params.planId || null); - - // 一键排课配置 - const [genVisible, setGenVisible] = useState(false); - const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner'); - const [genWithRests, setGenWithRests] = useState(true); - const [genWithNotes, setGenWithNotes] = useState(true); - const [genRest, setGenRest] = useState('30'); - - const selectedPlan = useMemo(() => plans.find(p => p.id === selectedPlanId), [plans, selectedPlanId]); - - // 监听选中计划变化,加载对应的排课数据 - useEffect(() => { - if (selectedPlanId) { - dispatch(loadExercises(selectedPlanId)); - } else { - dispatch(clearExercises()); - } - }, [selectedPlanId, dispatch]); - - // 每次页面获得焦点时,如果当前有选中的计划,重新加载其排课数据 - useFocusEffect( - useCallback(() => { - if (selectedPlanId) { - dispatch(loadExercises(selectedPlanId)); - } - }, [selectedPlanId, dispatch]) - ); - - // 每次页面获得焦点时重新加载训练计划数据 - useFocusEffect( - useCallback(() => { - dispatch(loadPlans()); - }, [dispatch]) - ); - - useEffect(() => { - if (error) { - console.error('训练计划错误:', error); - const timer = setTimeout(() => { - dispatch(clearError()); - }, 3000); - return () => clearTimeout(timer); - } - }, [error, dispatch]); - - useEffect(() => { - if (scheduleError) { - console.error('排课错误:', scheduleError); - const timer = setTimeout(() => { - dispatch(clearScheduleError()); - }, 3000); - return () => clearTimeout(timer); - } - }, [scheduleError, dispatch]); - - const handleActivate = async (planId: string) => { - try { - await dispatch(activatePlan(planId)); - } catch (error) { - console.error('激活训练计划失败:', error); - } - } - - const handlePlanSelect = (plan: TrainingPlan) => { - setSelectedPlanId(plan.id); - setActiveTab('schedule'); - } - - const handleTabChange = (tab: TabType) => { - if (tab === 'schedule' && !selectedPlanId && plans.length > 0) { - // 如果没有选中计划但要切换到排课页面,自动选择当前激活的计划或第一个计划 - const targetPlan = plans.find(p => p.isActive) || plans[0]; - setSelectedPlanId(targetPlan.id); - } - setActiveTab(tab); - } - - // 排课相关方法 - const handleAddExercise = () => { - router.push(`/training-plan/schedule/select?planId=${selectedPlanId}` as any); - }; - - const handleRemoveExercise = (exerciseId: string) => { - if (!selectedPlanId) return; - - Alert.alert('确认移除', '确定要移除该动作吗?', [ - { text: '取消', style: 'cancel' }, - { - text: '移除', - style: 'destructive', - onPress: () => { - dispatch(deleteExercise({ planId: selectedPlanId, exerciseId })); - }, - }, - ]); - }; - - const handleToggleCompleted = (exerciseId: string, currentCompleted: boolean) => { - if (!selectedPlanId) return; - - dispatch(toggleCompletion({ - planId: selectedPlanId, - exerciseId, - completed: !currentCompleted - })); - }; - - const onGenerate = async () => { - if (!selectedPlanId) return; - - const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10))); - const { items } = buildClassicalSession({ - withSectionRests: genWithRests, - restSeconds: restSec, - withNotes: genWithNotes, - level: genLevel - }); - - setGenVisible(false); - - try { - // 按顺序添加每个生成的训练项目 - for (const item of items) { - const dto = { - exerciseKey: item.key, // 使用key作为exerciseKey - name: item.name, - sets: item.sets, - reps: item.reps, - durationSec: item.durationSec, - restSec: item.restSec, - note: item.note, - itemType: item.itemType || 'exercise', - }; - - await dispatch(addExercise({ planId: selectedPlanId, dto })).unwrap(); - } - - Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。'); - } catch (error) { - console.error('生成排课失败:', error); - Alert.alert('生成失败', '请稍后重试'); - } - }; - - // 渲染训练计划列表 - const renderPlansList = () => ( - - - 我的训练计划 - 点击计划卡片进入排课模式,或使用底部切换 - - - {(error || scheduleError) && ( - - ⚠️ {error || scheduleError} - - )} - - {loading && plans.length === 0 ? ( - - - - - 加载中... - - ) : plans.length === 0 ? ( - - - 📋 - - 还没有训练计划 - 创建你的第一个计划开始训练吧 - router.push('/training-plan/create' as any)} style={styles.primaryBtn}> - 创建计划 - - - ) : ( - - {plans.map((p, index) => ( - handlePlanSelect(p)} - onDelete={() => dispatch(deletePlan(p.id))} - onActivate={() => handleActivate(p.id)} - onSchedule={() => handlePlanSelect(p)} - /> - ))} - {loading && ( - - 处理中... - - )} - - )} - - - - ); - - // 渲染排课页面 - const renderSchedulePage = () => { - if (!selectedPlan) { - return ( - - - 📅 - - 请先选择一个训练计划 - 切换到训练计划页面选择一个计划,或点击下方按钮 - setActiveTab('list')} - > - 选择计划 - - - ); - } - - const goalConfig = GOAL_TEXT[selectedPlan.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; - - return ( - - {/* 计划信息头部 */} - - - - {selectedPlan.name || goalConfig.title} - {goalConfig.description} - - - - {/* 操作按钮区域 */} - - - - 添加动作 - - - {/* setGenVisible(true)} - > - - 一键排课 - */} - - - {/* 动作列表 */} - item.id} - contentContainerStyle={styles.scheduleListContent} - showsVerticalScrollIndicator={false} - ListEmptyComponent={ - - - 💪 - - 还没有添加任何动作 - 点击"添加动作"开始排课,或使用"一键排课"快速生成 - - } - renderItem={({ item, index }) => { - const isRest = item.itemType === 'rest'; - const isNote = item.itemType === 'note'; - - if (isRest || isNote) { - return ( - - - - - {isRest ? `间隔休息 ${item.restSec ?? 30}s` : (item.note || '提示')} - - - handleRemoveExercise(item.id)} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - - ); - } - - return ( - - - - {item.name} - {item.exercise?.categoryName || '运动'} - - 组数 {item.sets || 1} - {item.reps ? ` · 每组 ${item.reps} 次` : ''} - {item.durationSec ? ` · 每组 ${item.durationSec}s` : ''} - - - - - handleToggleCompleted(item.id, item.completed)} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - - handleRemoveExercise(item.id)} - > - 移除 - - - - - ); - }} - /> - - ); - }; - - return ( - - {/* 动态背景 */} - - - - router.back()} - withSafeTop={false} - tone='light' - transparent={true} - right={ - activeTab === 'list' ? ( - router.push('/training-plan/create' as any)} style={styles.headerRightBtn}> - + 新建 - - ) : undefined - } - /> - - - {activeTab === 'list' ? renderPlansList() : renderSchedulePage()} - - - {/* 底部 Tab */} - - - {/* 一键排课配置弹窗 */} - {selectedPlan && ( - setGenVisible(false)}> - setGenVisible(false)}> - e.stopPropagation() as any}> - 一键排课配置 - - 强度水平 - - {(['beginner', 'intermediate', 'advanced'] as const).map((lv) => ( - setGenLevel(lv)} - > - - {lv === 'beginner' ? '入门' : lv === 'intermediate' ? '进阶' : '高级'} - - - ))} - - - - 段间休息 - - - - - 插入操作提示 - - - - - 休息秒数 - - - - - 生成训练计划 - - - - - )} - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - content: { - paddingHorizontal: 20, - paddingTop: 8, - }, - - // 动态背景 - backgroundOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - backgroundColor: 'rgba(187,242,70,0.15)', - top: -150, - right: -100, - }, - backgroundOrb2: { - position: 'absolute', - width: 400, - height: 400, - borderRadius: 200, - backgroundColor: 'rgba(164,138,237,0.12)', - bottom: -200, - left: -150, - }, - - // 页面标题区域 - headerSection: { - marginBottom: 20, - }, - title: { - fontSize: 28, - fontWeight: '800', - color: '#192126', - lineHeight: 34, - marginBottom: 4, - }, - subtitle: { - fontSize: 14, - color: '#5E6468', - opacity: 0.8, - }, - - // 训练计划列表 - plansList: { - gap: 10, - }, - - // 训练计划卡片 - planCard: { - borderRadius: 28, - overflow: 'hidden', - shadowOffset: { width: 0, height: 10 }, - shadowRadius: 20, - elevation: 8, - borderWidth: 1, - borderColor: '#3A261B', - backgroundColor: '#1F1410', - shadowColor: '#000', - }, - cardContent: { - position: 'relative', - }, - darkCard: { - backgroundColor: '#1F1410', - padding: 24, - borderRadius: 28, - }, - cardTintGradient: { - ...StyleSheet.absoluteFillObject, - borderRadius: 28, - }, - cardGradient: { - ...StyleSheet.absoluteFillObject, - borderRadius: 14, - }, - cardGlow: { - position: 'absolute', - top: -2, - left: -2, - right: -2, - bottom: -2, - borderRadius: 18, - backgroundColor: 'transparent', - }, - colorIndicator: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: 4, - borderTopLeftRadius: 16, - borderBottomLeftRadius: 16, - }, - cardMain: { - padding: 16, - paddingLeft: 20, - }, - cardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 12, - }, - titleSection: { - flex: 1, - }, - planTitle: { - fontSize: 17, - fontWeight: '800', - color: '#1A1E23', - marginBottom: 2, - }, - gradientTitle: { - fontSize: 34, - fontWeight: '800', - lineHeight: 40, - color: '#FFFFFF', - }, - planDescription: { - fontSize: 12, - color: '#6A5E58', - opacity: 0.9, - }, - darkSubtitle: { - marginTop: 16, - fontSize: 16, - color: '#E0D2C9', - lineHeight: 24, - }, - activeBadge: { - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 10, - marginLeft: 8, - }, - activeText: { - fontSize: 10, - fontWeight: '800', - color: palette.ink, - }, - cardInfo: { - flexDirection: 'row', - gap: 16, - }, - infoItem: { - flex: 1, - }, - infoLabel: { - fontSize: 10, - color: '#8A7F78', - marginBottom: 1, - fontWeight: '600', - }, - infoValue: { - fontSize: 13, - color: '#2F2A26', - fontWeight: '700', - }, - metricsRow: { - marginTop: 28, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - metricItem: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - borderRadius: 20, - }, - metricActive: { - backgroundColor: 'rgba(255,255,255,0.06)', - }, - metricText: { - marginLeft: 8, - color: '#E6EEF2', - fontSize: 16, - fontWeight: '700', - }, - - // 操作按钮区域 - actionButtons: { - flexDirection: 'row', - marginTop: 10, - gap: 6, - }, - actionButton: { - flex: 1, - paddingVertical: 6, - paddingHorizontal: 8, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - }, - scheduleButton: { - backgroundColor: 'transparent', - borderWidth: 1, - }, - activateButton: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.12, - shadowRadius: 3, - elevation: 3, - }, - activeIndicator: { - borderWidth: 1.5, - }, - actionButtonText: { - fontSize: 11, - fontWeight: '700', - }, - activateButtonText: { - fontSize: 11, - fontWeight: '700', - color: '#FFFFFF', - }, - activeIndicatorText: { - fontSize: 11, - fontWeight: '700', - }, - deleteButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: '#ED4747', - }, - deleteButtonText: { - fontSize: 11, - fontWeight: '700', - color: '#ED4747', - }, - - // 按钮样式 - primaryBtn: { - marginTop: 20, - backgroundColor: palette.primary, - paddingVertical: 14, - paddingHorizontal: 28, - borderRadius: 24, - alignItems: 'center', - shadowColor: palette.primary, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 6, - }, - primaryBtnText: { - color: palette.ink, - fontSize: 15, - fontWeight: '800', - }, - createBtn: { - backgroundColor: palette.primary, - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 22, - shadowColor: palette.primary, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - elevation: 4, - minWidth: 44, - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - }, - createBtnText: { - color: palette.ink, - fontWeight: '800', - fontSize: 14, - }, - - // 空状态 - emptyWrap: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - }, - emptyIcon: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: 'rgba(187,242,70,0.1)', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 16, - }, - emptyIconText: { - fontSize: 32, - }, - emptyText: { - fontSize: 18, - color: '#192126', - fontWeight: '600', - marginBottom: 4, - }, - emptySubtext: { - fontSize: 14, - color: '#5E6468', - textAlign: 'center', - marginBottom: 20, - }, - - // 加载状态 - loadingWrap: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - }, - loadingIcon: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: 'rgba(187,242,70,0.1)', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 16, - }, - loadingIconText: { - fontSize: 32, - }, - loadingText: { - fontSize: 18, - color: '#192126', - fontWeight: '600', - marginBottom: 4, - }, - loadingIndicator: { - alignItems: 'center', - paddingVertical: 20, - }, - loadingIndicatorText: { - fontSize: 14, - color: '#5E6468', - fontWeight: '600', - }, - - // 错误状态 - errorContainer: { - backgroundColor: 'rgba(237,71,71,0.1)', - borderRadius: 12, - padding: 16, - marginBottom: 16, - borderWidth: 1, - borderColor: 'rgba(237,71,71,0.2)', - }, - errorText: { - fontSize: 14, - color: '#ED4747', - fontWeight: '600', - textAlign: 'center', - }, - // 底部 Tab 样式(与主页一致) - bottomTabContainer: { - position: 'absolute', - bottom: 20, // TAB_BAR_BOTTOM_OFFSET - left: 0, - right: 0, - paddingHorizontal: 20, - }, - bottomTabBar: { - flexDirection: 'row', - height: 68, // TAB_BAR_HEIGHT - borderRadius: 34, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 10, - elevation: 5, - paddingHorizontal: 10, - paddingTop: 0, - paddingBottom: 0, - marginHorizontal: 0, - width: '100%', - alignSelf: 'center', - }, - tabButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginHorizontal: 6, - marginVertical: 10, - borderRadius: 25, - paddingHorizontal: 16, - paddingVertical: 8, - }, - tabText: { - fontSize: 12, - fontWeight: '600', - marginLeft: 6, - }, - - // 主内容区域 - mainContent: { - flex: 1, - paddingBottom: 60, // 为底部 tab 留出空间 - }, - - // 排课页面样式 - scheduleContent: { - flex: 1, - paddingHorizontal: 20, - }, - planHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 16, - marginBottom: 16, - }, - planColorIndicator: { - width: 4, - height: 40, - borderRadius: 2, - marginRight: 12, - }, - planInfo: { - flex: 1, - }, - - // 排课操作按钮 - actionRow: { - flexDirection: 'row', - gap: 12, - marginBottom: 20, - }, - scheduleActionBtn: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 4, - }, - scheduleActionBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - scheduleSecondaryBtn: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1.5, - backgroundColor: '#FFFFFF', - }, - scheduleSecondaryBtnText: { - fontSize: 14, - fontWeight: '700', - }, - - // 排课列表 - scheduleListContent: { - paddingBottom: 40, - }, - - // 动作卡片 - exerciseCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - exerciseContent: { - flexDirection: 'row', - alignItems: 'center', - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - exerciseCategory: { - fontSize: 12, - color: '#888F92', - marginBottom: 4, - }, - exerciseMeta: { - fontSize: 12, - color: '#5E6468', - }, - exerciseActions: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - completeBtn: { - padding: 4, - }, - removeBtn: { - backgroundColor: '#F3F4F6', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 8, - }, - removeBtnText: { - color: '#384046', - fontWeight: '700', - fontSize: 12, - }, - - // 内联项目(休息、提示) - inlineRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 10, - }, - inlineBadge: { - marginLeft: 6, - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 999, - paddingVertical: 6, - paddingHorizontal: 10, - flex: 1, - }, - inlineBadgeRest: { - backgroundColor: '#F8FAFC', - }, - inlineBadgeNote: { - backgroundColor: '#F9FAFB', - }, - inlineText: { - fontSize: 12, - fontWeight: '700', - }, - inlineTextItalic: { - fontSize: 12, - fontStyle: 'italic', - }, - inlineRemoveBtn: { - marginLeft: 6, - padding: 4, - borderRadius: 999, - }, - - // 空状态(排课页面) - emptyContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - }, - - // 未选择计划状态 - noSelectionContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - paddingHorizontal: 20, - }, - noSelectionIcon: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: 'rgba(187,242,70,0.1)', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 16, - }, - noSelectionIconText: { - fontSize: 32, - }, - noSelectionText: { - fontSize: 18, - color: '#192126', - fontWeight: '600', - marginBottom: 4, - textAlign: 'center', - }, - noSelectionSubtext: { - fontSize: 14, - color: '#5E6468', - textAlign: 'center', - marginBottom: 20, - }, - - - // 弹窗样式 - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - alignItems: 'center', - justifyContent: 'flex-end', - }, - modalSheet: { - width: '100%', - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingHorizontal: 16, - paddingTop: 14, - paddingBottom: 24, - }, - modalTitle: { - fontSize: 16, - fontWeight: '800', - marginBottom: 16, - color: '#192126', - }, - modalLabel: { - fontSize: 12, - color: '#888F92', - marginBottom: 8, - fontWeight: '600', - }, - segmentedRow: { - flexDirection: 'row', - gap: 8, - marginBottom: 16, - }, - segment: { - flex: 1, - borderRadius: 999, - borderWidth: 1, - borderColor: '#E5E7EB', - paddingVertical: 8, - alignItems: 'center', - }, - segmentText: { - fontWeight: '700', - color: '#384046', - }, - switchRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 12, - }, - switchLabel: { - fontWeight: '700', - color: '#384046', - }, - inputRow: { - marginBottom: 20, - }, - inputLabel: { - fontSize: 12, - color: '#888F92', - marginBottom: 8, - fontWeight: '600', - }, - input: { - height: 40, - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 10, - paddingHorizontal: 12, - color: '#384046', - }, - generateBtn: { - paddingVertical: 12, - borderRadius: 12, - alignItems: 'center', - }, - generateBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 14, - }, - - // 顶部导航右侧按钮(与 HeaderBar 标准尺寸一致,使用 tab 配色) - headerRightBtn: { - width: 52, - height: 32, - backgroundColor: palette.primary, // 使用 tab 的主色 - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - }, - headerRightBtnText: { - color: palette.ink, - fontWeight: '800', - fontSize: 10, - }, - - // 统计显示 - statsContainer: { - paddingHorizontal: 12, - paddingVertical: 4, - backgroundColor: 'rgba(187,242,70,0.2)', - borderRadius: 16, - }, - statsText: { - fontSize: 12, - fontWeight: '800', - color: palette.ink, - }, -}); diff --git a/app/training-plan/create.tsx b/app/training-plan/create.tsx deleted file mode 100644 index 0d8bb1c..0000000 --- a/app/training-plan/create.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import DateTimePicker from '@react-native-community/datetimepicker'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useState } from 'react'; -import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native'; - -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { PlanGoal } from '@/services/trainingPlanApi'; -import { - clearError, - loadPlans, - saveDraftAsPlan, - setGoal, - setMode, - setName, - setPreferredTime, - setSessionsPerWeek, - setStartDate, - setStartDateNextMonday, - setStartWeight, - toggleDayOfWeek, -} from '@/store/trainingPlanSlice'; - -const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六']; -const GOALS: { key: PlanGoal; title: string; desc: string }[] = [ - { key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' }, - { key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' }, - { key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' }, - { key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' }, - { key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' }, - { key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' }, - { key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' }, -]; - -export default function TrainingPlanCreateScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const { draft, loading, error, editingId } = useAppSelector((s) => s.trainingPlan); - const { id } = useLocalSearchParams<{ id?: string }>(); - const [weightInput, setWeightInput] = useState(''); - const [datePickerVisible, setDatePickerVisible] = useState(false); - const [pickerDate, setPickerDate] = useState(new Date()); - - useEffect(() => { - dispatch(loadPlans()); - }, [dispatch]); - - // 如果带有 id,加载详情并进入编辑模式 - useEffect(() => { - if (id) { - dispatch({ type: 'trainingPlan/clearError' } as any); - dispatch((require('@/store/trainingPlanSlice') as any).loadPlanForEdit(id as string)); - } else { - // 离开编辑模式 - dispatch((require('@/store/trainingPlanSlice') as any).setEditingId(null)); - } - }, [id, dispatch]); - - useEffect(() => { - if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg)); - }, [draft.startWeightKg]); - - const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek; - - const canSave = useMemo(() => { - if (!draft.goal) return false; - if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false; - if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false; - return true; - }, [draft]); - - const formattedStartDate = useMemo(() => { - const d = new Date(draft.startDate); - try { - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'short', - }).format(d); - } catch { - return d.toLocaleDateString('zh-CN'); - } - }, [draft.startDate]); - - const handleSave = async () => { - try { - if (editingId) { - await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap(); - } else { - await dispatch(saveDraftAsPlan()).unwrap(); - } - router.back(); - } catch (error) { - // 错误已经在Redux中处理,这里可以显示额外的用户反馈 - console.error('保存训练计划失败:', error); - } - }; - - useEffect(() => { - if (error) { - // 3秒后自动清除错误 - const timer = setTimeout(() => { - dispatch(clearError()); - }, 3000); - return () => clearTimeout(timer); - } - }, [error, dispatch]); - - const openDatePicker = () => { - const base = draft.startDate ? new Date(draft.startDate) : new Date(); - base.setHours(0, 0, 0, 0); - setPickerDate(base); - setDatePickerVisible(true); - }; - const closeDatePicker = () => setDatePickerVisible(false); - const onConfirmDate = (date: Date) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const picked = new Date(date); - picked.setHours(0, 0, 0, 0); - const finalDate = picked < today ? today : picked; - dispatch(setStartDate(finalDate.toISOString())); - closeDatePicker(); - }; - - return ( - - - router.back()} withSafeTop={false} transparent /> - - 制定你的训练计划 - 选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。 - - {error && ( - - ⚠️ {error} - - )} - - - 计划名称 - dispatch(setName(text))} - style={styles.nameInput} - maxLength={50} - /> - - - - 训练频率 - - dispatch(setMode('daysOfWeek'))} - style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]} - > - 按星期选择 - - dispatch(setMode('sessionsPerWeek'))} - style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]} - > - 每周次数 - - - - {draft.mode === 'daysOfWeek' ? ( - - {WEEK_DAYS.map((d, i) => { - const active = draft.daysOfWeek.includes(i); - return ( - dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}> - {d} - - ); - })} - - ) : ( - - 每周训练 - - dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}> - - - - {draft.sessionsPerWeek} - dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}> - + - - - - - )} - - 已选择:{selectedCount} 次/周 - - - - 训练目标 - - {GOALS.map((g) => { - const active = draft.goal === g.key; - return ( - dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}> - {g.title} - {g.desc} - - ); - })} - - - - - 更多选项 - - 开始日期 - - - 选择日期 - - dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}> - 下周一 - - - - {formattedStartDate} - - - 开始体重 (kg) - { - setWeightInput(t); - const v = Number(t); - dispatch(setStartWeight(Number.isFinite(v) ? v : undefined)); - }} - style={styles.input} - /> - - - - 偏好时间段 - - {(['morning', 'noon', 'evening', ''] as const).map((k) => ( - dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}> - {k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'} - - ))} - - - - - - - {loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'} - - - - - - - - - - { - if (Platform.OS === 'ios') { - if (date) setPickerDate(date); - } else { - if (event.type === 'set' && date) { - onConfirmDate(date); - } else { - closeDatePicker(); - } - } - }} - /> - {Platform.OS === 'ios' && ( - - - 取消 - - { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}> - 确定 - - - )} - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: '#F7F8FA', - }, - container: { - flex: 1, - backgroundColor: '#F7F8FA', - }, - content: { - paddingHorizontal: 20, - paddingTop: 16, - }, - title: { - fontSize: 28, - fontWeight: '800', - color: '#1A1A1A', - lineHeight: 36, - }, - subtitle: { - fontSize: 14, - color: '#5E6468', - marginTop: 6, - marginBottom: 16, - lineHeight: 20, - }, - card: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginTop: 14, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - cardTitle: { - fontSize: 18, - fontWeight: '700', - color: '#0F172A', - marginBottom: 12, - }, - segment: { - flexDirection: 'row', - backgroundColor: '#F1F5F9', - padding: 4, - borderRadius: 999, - }, - segmentItem: { - flex: 1, - borderRadius: 999, - paddingVertical: 10, - alignItems: 'center', - }, - segmentItemActive: { - backgroundColor: palette.primary, - }, - segmentText: { - fontSize: 14, - color: '#475569', - fontWeight: '600', - }, - segmentTextActive: { - color: palette.ink, - }, - weekRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 14, - }, - dayChip: { - width: 44, - height: 44, - borderRadius: 12, - backgroundColor: '#F1F5F9', - alignItems: 'center', - justifyContent: 'center', - }, - dayChipActive: { - backgroundColor: '#E0F8A2', - borderWidth: 2, - borderColor: palette.primary, - }, - dayChipText: { - fontSize: 16, - color: '#334155', - fontWeight: '700', - }, - dayChipTextActive: { - color: '#0F172A', - }, - sliderRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 16, - }, - sliderLabel: { - fontSize: 16, - color: '#334155', - fontWeight: '700', - }, - counter: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: 12, - }, - counterBtn: { - width: 36, - height: 36, - borderRadius: 999, - backgroundColor: '#F1F5F9', - alignItems: 'center', - justifyContent: 'center', - }, - counterBtnText: { - fontSize: 18, - fontWeight: '800', - color: '#0F172A', - }, - counterValue: { - width: 44, - textAlign: 'center', - fontSize: 18, - fontWeight: '800', - color: '#0F172A', - }, - sliderSuffix: { - marginLeft: 8, - color: '#475569', - }, - helper: { - marginTop: 10, - color: '#5E6468', - }, - goalGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - goalItem: { - width: '48%', - backgroundColor: '#F8FAFC', - borderRadius: 14, - padding: 12, - marginBottom: 12, - }, - goalItemActive: { - backgroundColor: '#E0F8A2', - borderColor: palette.primary, - borderWidth: 2, - }, - goalTitle: { - fontSize: 16, - fontWeight: '800', - color: '#0F172A', - }, - goalTitleActive: { - color: '#0F172A', - }, - goalDesc: { - marginTop: 6, - fontSize: 12, - color: '#5E6468', - lineHeight: 16, - }, - rowBetween: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginTop: 6, - }, - rowRight: { - flexDirection: 'row', - alignItems: 'center', - }, - label: { - fontSize: 14, - color: '#0F172A', - fontWeight: '700', - }, - linkBtn: { - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 999, - backgroundColor: '#F1F5F9', - }, - linkText: { - color: '#334155', - fontWeight: '700', - }, - dateHint: { - marginTop: 6, - color: '#5E6468', - }, - input: { - marginLeft: 12, - backgroundColor: '#F1F5F9', - paddingHorizontal: 10, - paddingVertical: 8, - borderRadius: 8, - minWidth: 88, - textAlign: 'right', - color: '#0F172A', - }, - segmentSmall: { - flexDirection: 'row', - backgroundColor: '#F1F5F9', - padding: 3, - borderRadius: 999, - }, - segmentItemSmall: { - borderRadius: 999, - paddingVertical: 6, - paddingHorizontal: 10, - marginHorizontal: 3, - }, - segmentItemActiveSmall: { - backgroundColor: palette.primary, - }, - segmentTextSmall: { - fontSize: 12, - color: '#475569', - fontWeight: '700', - }, - segmentTextActiveSmall: { - color: palette.ink, - }, - primaryBtn: { - marginTop: 18, - backgroundColor: palette.primary, - paddingVertical: 14, - borderRadius: 14, - alignItems: 'center', - }, - primaryBtnDisabled: { - opacity: 0.5, - }, - primaryBtnText: { - color: palette.ink, - fontSize: 16, - fontWeight: '800', - }, - modalBackdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.35)', - }, - modalSheet: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - padding: 16, - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - }, - modalActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - marginTop: 8, - gap: 12, - }, - modalBtn: { - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 10, - backgroundColor: '#F1F5F9', - }, - modalBtnPrimary: { - backgroundColor: palette.primary, - }, - modalBtnText: { - color: '#334155', - fontWeight: '700', - }, - modalBtnTextPrimary: { - color: palette.ink, - }, - - // 计划名称输入框 - nameInput: { - backgroundColor: '#F1F5F9', - paddingHorizontal: 12, - paddingVertical: 12, - borderRadius: 8, - fontSize: 16, - color: '#0F172A', - marginTop: 8, - }, - - // 错误状态 - errorContainer: { - backgroundColor: 'rgba(237,71,71,0.1)', - borderRadius: 12, - padding: 16, - marginTop: 16, - borderWidth: 1, - borderColor: 'rgba(237,71,71,0.2)', - }, - errorText: { - fontSize: 14, - color: '#ED4747', - fontWeight: '600', - textAlign: 'center', - }, -}); - - diff --git a/app/training-plan/schedule/select.tsx b/app/training-plan/schedule/select.tsx deleted file mode 100644 index a82a4f5..0000000 --- a/app/training-plan/schedule/select.tsx +++ /dev/null @@ -1,1116 +0,0 @@ -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { loadExerciseLibrary } from '@/store/exerciseLibrarySlice'; -import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary'; -import { Ionicons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Animated, FlatList, LayoutAnimation, Modal, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, UIManager, View } from 'react-native'; - -import { ThemedText } from '@/components/ThemedText'; -import { addExercise } from '@/store/scheduleExerciseSlice'; -import { addWorkoutExercise } from '@/store/workoutSlice'; - -const GOAL_TEXT: Record = { - postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, - fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, - posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, - core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, - flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, - rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, - stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, -}; - -// 动态背景组件 -function DynamicBackground({ color }: { color: string }) { - return ( - - - - - - ); -} - -export default function SelectExerciseForScheduleScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const params = useLocalSearchParams<{ planId?: string; sessionId?: string }>(); - const { plans } = useAppSelector((s) => s.trainingPlan); - const { currentSession } = useAppSelector((s) => s.workout); - - const planId = params.planId; - const sessionId = params.sessionId; - const plan = useMemo(() => plans.find(p => p.id === planId), [plans, planId]); - - // 会话状态管理 - const [session, setSession] = useState(null); - const [sessionLoading, setSessionLoading] = useState(false); - - // 根据是否有sessionId来确定是训练计划模式还是训练会话模式 - const isSessionMode = !!sessionId; - - // 加载会话详情(如果是会话模式) - useEffect(() => { - if (sessionId && !session) { - const loadSession = async () => { - try { - setSessionLoading(true); - // 首先尝试使用 currentSession(如果 sessionId 匹配) - if (currentSession?.id === sessionId) { - setSession(currentSession); - } else { - // 否则从 API 获取会话详情 - const { workoutsApi } = await import('@/services/workoutsApi'); - const sessionDetail = await workoutsApi.getSessionDetail(sessionId); - setSession(sessionDetail); - } - } catch (error) { - console.error('加载会话详情失败:', error); - } finally { - setSessionLoading(false); - } - }; - loadSession(); - } - }, [sessionId, currentSession, session]); - - const targetGoal = plan?.goal || session?.trainingPlan?.goal; - const goalConfig = targetGoal - ? (GOAL_TEXT[targetGoal] || { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }) - : { title: isSessionMode ? '添加动作' : '训练计划', color: palette.primary, description: isSessionMode ? '选择要添加的动作' : '开始你的训练之旅' }; - - const [keyword, setKeyword] = useState(''); - const [category, setCategory] = useState('全部'); - const [selectedKey, setSelectedKey] = useState(null); - const [sets, setSets] = useState(3); - const [reps, setReps] = useState(undefined); - const [showCustomReps, setShowCustomReps] = useState(false); - const [customRepsInput, setCustomRepsInput] = useState(''); - const [showCategoryPicker, setShowCategoryPicker] = useState(false); - const [showRestModal, setShowRestModal] = useState(false); - const [showNoteModal, setShowNoteModal] = useState(false); - const [restDuration, setRestDuration] = useState(30); - const [noteContent, setNoteContent] = useState(''); - const { categories: serverCategoryDtos, exercises: serverExercises } = useAppSelector((s) => s.exerciseLibrary); - const [adding, setAdding] = useState(false); - - const controlsOpacity = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); - } - }, []); - - useEffect(() => { - dispatch(loadExerciseLibrary()); - }, [dispatch]); - - const categories = useMemo(() => { - const base = serverCategoryDtos && serverCategoryDtos.length - ? serverCategoryDtos.map((c) => c.name) - : getCategories(); - const unique = Array.from(new Set(base)); - return ['全部', ...unique]; - }, [serverCategoryDtos]); - - const mainCategories = useMemo(() => { - const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑']; - const exists = (name: string) => categories.includes(name); - const picked = preferred.filter(exists); - const rest = categories.filter((c) => !picked.includes(c)); - while (picked.length < 5 && rest.length) picked.push(rest.shift() as string); - return picked; - }, [categories]); - - const library = useMemo(() => (serverExercises && serverExercises.length ? serverExercises : EXERCISE_LIBRARY), [serverExercises]); - - const filtered = useMemo(() => { - const kw = keyword.trim().toLowerCase(); - const base = kw - ? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw)) - : library; - if (category === '全部') return base; - return base.filter((e) => e.category === category); - }, [keyword, category, library]); - - const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]); - - useEffect(() => { - Animated.timing(controlsOpacity, { - toValue: selected ? 1 : 0, - duration: selected ? 220 : 160, - useNativeDriver: true, - }).start(); - }, [selected, controlsOpacity]); - - const handleAdd = async () => { - if (!selected || adding) return; - - console.log('选择动作:', selected); - - const newExerciseDto = { - exerciseKey: selected.key, - name: selected.name, - plannedSets: sets, - plannedReps: reps, - itemType: 'exercise' as const, - note: `${selected.category}训练`, - }; - - setAdding(true); - try { - if (isSessionMode && sessionId) { - // 训练会话模式:添加到训练会话 - await dispatch(addWorkoutExercise({ sessionId, dto: newExerciseDto })).unwrap(); - } else if (plan && planId) { - // 训练计划模式:添加到训练计划 - const planExerciseDto = { - exerciseKey: selected.key, - name: selected.name, - sets: sets, - reps: reps, - itemType: 'exercise' as const, - note: `${selected.category}训练`, - }; - await dispatch(addExercise({ planId: planId, dto: planExerciseDto })).unwrap(); - } else { - throw new Error('缺少必要的参数'); - } - - // 返回到上一页 - router.back(); - } catch (error) { - console.error('添加动作失败:', error); - Alert.alert('添加失败', '添加动作时出现错误,请稍后重试'); - } finally { - setAdding(false); - } - }; - - // 添加休息项目 - const handleAddRest = () => { - setShowRestModal(true); - Haptics.selectionAsync(); - }; - - // 添加备注项目 - const handleAddNote = () => { - setShowNoteModal(true); - Haptics.selectionAsync(); - }; - - // 确认添加休息 - const confirmAddRest = async () => { - if (adding) return; - - const restDto = { - name: `间隔休息 ${restDuration}s`, - restSec: restDuration, - itemType: 'rest' as const, - }; - - setAdding(true); - try { - if (isSessionMode && sessionId) { - // 训练会话模式 - await dispatch(addWorkoutExercise({ sessionId, dto: restDto })).unwrap(); - } else if (plan && planId) { - // 训练计划模式 - await dispatch(addExercise({ planId: planId, dto: restDto })).unwrap(); - } else { - throw new Error('缺少必要的参数'); - } - - setShowRestModal(false); - setRestDuration(30); - router.back(); - } catch (error) { - console.error('添加休息失败:', error); - Alert.alert('添加失败', '添加休息时出现错误,请稍后重试'); - } finally { - setAdding(false); - } - }; - - // 确认添加备注 - const confirmAddNote = async () => { - if (adding || !noteContent.trim()) return; - - const noteDto = { - name: '训练提示', - note: noteContent.trim(), - itemType: 'note' as const, - }; - - setAdding(true); - try { - if (isSessionMode && sessionId) { - // 训练会话模式 - await dispatch(addWorkoutExercise({ sessionId, dto: noteDto })).unwrap(); - } else if (plan && planId) { - // 训练计划模式 - await dispatch(addExercise({ planId: planId, dto: noteDto })).unwrap(); - } else { - throw new Error('缺少必要的参数'); - } - - setShowNoteModal(false); - setNoteContent(''); - router.back(); - } catch (error) { - console.error('添加备注失败:', error); - Alert.alert('添加失败', '添加备注时出现错误,请稍后重试'); - } finally { - setAdding(false); - } - }; - - const onSelectItem = (key: string) => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - if (selectedKey === key) { - setSelectedKey(null); - return; - } - const sel = library.find((e) => e.key === key) as any; - setSets(sel?.beginnerSets ?? 3); - setReps(sel?.beginnerReps); - setShowCustomReps(false); - setCustomRepsInput(''); - setSelectedKey(key); - }; - - // 加载状态 - if (sessionLoading) { - return ( - - router.back()} /> - - 加载中... - - - ); - } - - // 错误状态检查 - if (isSessionMode && !session) { - return ( - - router.back()} /> - - 找不到指定的训练会话 - - - ); - } - - if (!isSessionMode && !plan) { - return ( - - router.back()} /> - - 找不到指定的训练计划 - - - ); - } - - return ( - - {/* 动态背景 */} - - - - router.back()} - withSafeTop={false} - transparent={true} - tone="light" - /> - - - {/* 计划信息头部 */} - - - - {goalConfig.title} - - {isSessionMode ? '为当前训练会话添加动作' : '选择动作或添加休息、备注项目'} - - - - - {/* 快捷添加区域 */} - - - - 添加休息 - - - - - 添加备注 - - - - {/* 大分类宫格 */} - - {[...mainCategories, '更多'].map((item) => { - const active = category === item; - const meta: Record = { - 全部: { bg: `${goalConfig.color}22` }, - 核心与腹部: { bg: `${goalConfig.color}18` }, - 脊柱与后链: { bg: 'rgba(149,204,227,0.20)' }, - 侧链与髋: { bg: 'rgba(164,138,237,0.20)' }, - 平衡与支撑: { bg: 'rgba(252,196,111,0.22)' }, - 进阶控制: { bg: 'rgba(237,71,71,0.18)' }, - 柔韧与拉伸: { bg: 'rgba(149,204,227,0.18)' }, - 更多: { bg: 'rgba(24,24,27,0.06)' }, - }; - const scale = new Animated.Value(1); - const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const handlePress = () => { - onPressOut(); - if (item === '更多') { - setShowCategoryPicker(true); - Haptics.selectionAsync(); - } else { - setCategory(item); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - }; - return ( - - - - {item} - - - - ); - })} - - - {/* 分类选择弹层 */} - setShowCategoryPicker(false)} - > - setShowCategoryPicker(false)}> - e.stopPropagation() as any} - > - 选择分类 - - {categories.filter((c) => c !== '全部').map((c) => { - const scale = new Animated.Value(1); - const onPressIn = () => Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - return ( - - { - onPressOut(); - setCategory(c); - setShowCategoryPicker(false); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - activeOpacity={0.9} - style={[styles.catTile, { backgroundColor: 'rgba(24,24,27,0.06)' }]} - > - {c} - - - ); - })} - - - - - - {/* 搜索框 */} - - - - - {/* 动作列表 */} - item.key} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={false} - renderItem={({ item }) => { - const isSelected = item.key === selectedKey; - return ( - onSelectItem(item.key)} - activeOpacity={0.9} - > - - {item.name} - {item.category} - {((item as any).targetMuscleGroups || (item as any).equipmentName) && ( - - {[(item as any).targetMuscleGroups, (item as any).equipmentName].filter(Boolean).join(' · ')} - - )} - {(((item as any).beginnerSets || (item as any).beginnerReps)) && ( - - 建议 {(item as any).beginnerSets ?? '-'} 组 × {(item as any).beginnerReps ?? '-'} 次 - - )} - {item.description} - - {isSelected && } - {isSelected && ( - - - - 组数 - - setSets(Math.max(1, sets - 1))} - > - - - - {sets} - setSets(Math.min(20, sets + 1))} - > - + - - - - - - 每组次数 - - {[6, 8, 10, 12, 15, 20, 25, 30].map((v) => { - const active = reps === v; - return ( - { - setReps(v); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - {v} - - ); - })} - { - setShowCustomReps((s) => !s); - Haptics.selectionAsync(); - }} - > - 自定义 - - - {showCustomReps && ( - - - { - const n = Math.max(1, Math.min(100, parseInt(customRepsInput || '0', 10))); - if (!Number.isNaN(n)) { - setReps(n); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - }} - > - 确定 - - - )} - - - - - - {adding ? '添加中...' : (isSessionMode ? '添加到训练会话' : '添加到训练计划')} - - - - )} - - ); - }} - /> - - - - {/* 休息时间配置模态框 */} - setShowRestModal(false)}> - setShowRestModal(false)}> - e.stopPropagation() as any}> - 设置休息时间 - - - {[15, 30, 45, 60, 90, 120].map((v) => { - const active = restDuration === v; - return ( - { - setRestDuration(v); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - {v}s - - ); - })} - - - - 自定义时间 - - { - const num = parseInt(text) || 30; - setRestDuration(Math.max(10, Math.min(300, num))); - }} - keyboardType="number-pad" - style={styles.customRestInput} - /> - - - - - - {adding ? '添加中...' : '确认添加'} - - - - - - {/* 备注配置模态框 */} - setShowNoteModal(false)}> - setShowNoteModal(false)}> - e.stopPropagation() as any}> - 添加训练提示 - - - - - {noteContent.length}/100 - {noteContent.length > 0 && ( - setNoteContent('')} - style={styles.noteClearBtn} - > - - - )} - - - - {adding ? '添加中...' : '确认添加'} - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: 20, - }, - - // 动态背景 - backgroundOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - top: -150, - right: -100, - }, - backgroundOrb2: { - position: 'absolute', - width: 400, - height: 400, - borderRadius: 200, - bottom: -200, - left: -150, - }, - - // 计划信息头部 - planHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 16, - marginBottom: 16, - }, - planColorIndicator: { - width: 4, - height: 40, - borderRadius: 2, - marginRight: 12, - }, - planInfo: { - flex: 1, - }, - planTitle: { - fontSize: 18, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - planDescription: { - fontSize: 13, - color: '#5E6468', - opacity: 0.8, - }, - - // 快捷添加区域 - quickAddSection: { - flexDirection: 'row', - gap: 12, - marginBottom: 16, - }, - quickAddBtn: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, - borderWidth: 1, - gap: 8, - }, - quickAddText: { - fontSize: 14, - fontWeight: '700', - }, - - // 分类网格 - catGrid: { - paddingTop: 10, - flexDirection: 'row', - flexWrap: 'wrap', - marginBottom: 16, - }, - catTileWrapper: { - width: '33.33%', - padding: 6, - }, - catTile: { - borderRadius: 14, - paddingVertical: 16, - paddingHorizontal: 8, - alignItems: 'center', - justifyContent: 'center', - }, - catText: { - fontSize: 13, - fontWeight: '700', - color: '#384046', - }, - - // 搜索框 - searchRow: { - marginBottom: 16, - }, - searchInput: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - color: '#384046', - borderWidth: 1, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - - // 列表 - listContent: { - paddingBottom: 40, - }, - - // 动作卡片 - itemCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 12, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 3, - }, - itemTitle: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - itemMeta: { - fontSize: 12, - color: '#888F92', - marginBottom: 4, - }, - itemDesc: { - fontSize: 12, - color: '#5E6468', - lineHeight: 16, - }, - - // 展开的控制区域 - expandedBox: { - marginTop: 12, - }, - controlsRow: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 12, - flexWrap: 'wrap', - marginBottom: 16, - }, - counterBox: { - backgroundColor: '#F8F9FA', - borderRadius: 8, - padding: 12, - minWidth: 120, - }, - counterLabel: { - fontSize: 10, - color: '#888F92', - marginBottom: 8, - fontWeight: '600', - }, - counterRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - counterBtn: { - backgroundColor: '#E5E7EB', - width: 28, - height: 28, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - }, - counterBtnText: { - fontWeight: '800', - color: '#384046', - }, - counterValue: { - minWidth: 40, - textAlign: 'center', - fontWeight: '700', - color: '#384046', - }, - repsChipsRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginTop: 6, - }, - repChip: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 999, - backgroundColor: '#F3F4F6', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - repChipText: { - color: '#384046', - fontWeight: '700', - fontSize: 12, - }, - repChipGhost: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 999, - borderWidth: 1, - backgroundColor: 'transparent', - borderColor: '#E5E7EB', - }, - repChipGhostText: { - fontWeight: '700', - color: '#384046', - fontSize: 12, - }, - customRepsRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - marginTop: 8, - }, - customRepsInput: { - flex: 1, - height: 40, - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 10, - paddingHorizontal: 12, - color: '#384046', - }, - customRepsBtn: { - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 10, - }, - customRepsBtnText: { - fontWeight: '800', - color: '#FFFFFF', - fontSize: 12, - }, - - // 休息时间配置 - restTimeRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 16, - }, - restChip: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 16, - borderWidth: 1, - borderColor: '#E5E7EB', - backgroundColor: '#FFFFFF', - }, - restChipText: { - fontSize: 12, - fontWeight: '700', - color: '#384046', - }, - - // 模态框自定义休息时间 - customRestSection: { - marginBottom: 20, - }, - sectionLabel: { - fontSize: 14, - fontWeight: '700', - color: '#384046', - marginBottom: 10, - }, - customRestRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - customRestInput: { - flex: 1, - height: 40, - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 10, - paddingHorizontal: 12, - color: '#384046', - fontSize: 16, - textAlign: 'center', - }, - customRestUnit: { - fontSize: 14, - color: '#384046', - fontWeight: '600', - }, - - // 备注模态框 - noteModalInput: { - minHeight: 100, - maxHeight: 150, - borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - color: '#384046', - fontSize: 14, - textAlignVertical: 'top', - backgroundColor: '#FFFFFF', - marginBottom: 10, - }, - noteModalInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 20, - }, - noteCounter: { - fontSize: 11, - color: '#888F92', - }, - noteClearBtn: { - padding: 4, - }, - - // 确认按钮 - confirmBtn: { - paddingVertical: 12, - borderRadius: 12, - alignItems: 'center', - marginTop: 10, - }, - confirmBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 14, - }, - - addBtn: { - marginTop: 20, - paddingVertical: 12, - borderRadius: 12, - alignItems: 'center', - }, - addBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 14, - }, - - // 错误状态 - errorContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - errorText: { - fontSize: 16, - color: '#ED4747', - fontWeight: '600', - }, - - // 弹窗样式 - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - alignItems: 'center', - justifyContent: 'flex-end', - }, - modalSheet: { - width: '100%', - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - paddingHorizontal: 16, - paddingTop: 14, - paddingBottom: 24, - }, - modalTitle: { - fontSize: 16, - fontWeight: '800', - marginBottom: 16, - color: '#192126', - }, - catGridModal: { - flexDirection: 'row', - flexWrap: 'wrap', - }, -}); \ No newline at end of file diff --git a/app/voice-record.tsx b/app/voice-record.tsx index 4cad8d2..9c8a790 100644 --- a/app/voice-record.tsx +++ b/app/voice-record.tsx @@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { analyzeFoodFromText } from '@/services/foodRecognition'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { triggerHapticFeedback } from '@/utils/haptics'; @@ -22,6 +23,7 @@ import { type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing'; export default function VoiceRecordScreen() { + const safeAreaTop = useSafeAreaTop() const theme = useColorScheme() ?? 'light'; const colorTokens = Colors[theme]; const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>(); @@ -460,6 +462,10 @@ export default function VoiceRecordScreen() { variant="elevated" /> + + {/* 上半部分:介绍 */} diff --git a/app/water/detail.tsx b/app/water/detail.tsx index 47ce8cf..b4a9e62 100644 --- a/app/water/detail.tsx +++ b/app/water/detail.tsx @@ -1,10 +1,8 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useWaterDataByDate } from '@/hooks/useWaterData'; -import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; -import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences'; +import { getQuickWaterAmount } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; -import { Picker } from '@react-native-picker/picker'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { router, useLocalSearchParams } from 'expo-router'; @@ -12,12 +10,9 @@ import React, { useEffect, useState } from 'react'; import { Alert, KeyboardAvoidingView, - Modal, Platform, - Pressable, ScrollView, StyleSheet, - Switch, Text, TouchableOpacity, View @@ -25,6 +20,7 @@ import { import { Swipeable } from 'react-native-gesture-handler'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import dayjs from 'dayjs'; interface WaterDetailProps { @@ -32,6 +28,8 @@ interface WaterDetailProps { } const WaterDetail: React.FC = () => { + const safeAreaTop = useSafeAreaTop() + const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -194,7 +192,9 @@ const WaterDetail: React.FC = () => { > diff --git a/app/water/reminder-settings.tsx b/app/water/reminder-settings.tsx index 098f47a..089cef0 100644 --- a/app/water/reminder-settings.tsx +++ b/app/water/reminder-settings.tsx @@ -22,8 +22,10 @@ import { } from 'react-native'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; const WaterReminderSettings: React.FC = () => { + const safeAreaTop = useSafeAreaTop() const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -186,7 +188,9 @@ const WaterReminderSettings: React.FC = () => { > {/* 开启/关闭提醒 */} diff --git a/app/water/settings.tsx b/app/water/settings.tsx index ebaa889..445689b 100644 --- a/app/water/settings.tsx +++ b/app/water/settings.tsx @@ -3,10 +3,10 @@ import { useColorScheme } from '@/hooks/useColorScheme'; import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { Picker } from '@react-native-picker/picker'; +import { useFocusEffect } from '@react-navigation/native'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; import React, { useCallback, useEffect, useState } from 'react'; -import { useFocusEffect } from '@react-navigation/native'; import { Alert, KeyboardAvoidingView, @@ -15,15 +15,16 @@ import { Pressable, ScrollView, StyleSheet, - Switch, Text, TouchableOpacity, View } from 'react-native'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; const WaterSettings: React.FC = () => { + const safeAreaTop = useSafeAreaTop() const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -143,7 +144,7 @@ const WaterSettings: React.FC = () => { > {/* 设置列表 */} diff --git a/app/weight-records.tsx b/app/weight-records.tsx index c00c694..b0f32ae 100644 --- a/app/weight-records.tsx +++ b/app/weight-records.tsx @@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; @@ -22,6 +23,8 @@ import { } from 'react-native'; export default function WeightRecordsPage() { + const safeAreaTop = useSafeAreaTop() + const dispatch = useAppDispatch(); const userProfile = useAppSelector((s) => s.user.profile); const weightHistory = useAppSelector((s) => s.user.weightHistory); @@ -184,8 +187,12 @@ export default function WeightRecordsPage() { } /> + + {/* Weight Statistics */} - + {totalWeightLoss.toFixed(1)}kg diff --git a/app/workout/create-session.tsx b/app/workout/create-session.tsx deleted file mode 100644 index 9a80f4f..0000000 --- a/app/workout/create-session.tsx +++ /dev/null @@ -1,516 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import dayjs from 'dayjs'; -import * as Haptics from 'expo-haptics'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useEffect, useState } from 'react'; -import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; -import Animated, { FadeInUp } from 'react-native-reanimated'; - -import { ThemedText } from '@/components/ThemedText'; -import { HeaderBar } from '@/components/ui/HeaderBar'; -import { palette } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { loadPlans } from '@/store/trainingPlanSlice'; -import { createWorkoutSession } from '@/store/workoutSlice'; - -const GOAL_TEXT: Record = { - postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' }, - fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' }, - posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' }, - core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' }, - flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' }, - rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' }, - stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' }, -}; - -// 动态背景组件 -function DynamicBackground({ color }: { color: string }) { - return ( - - - - - - ); -} - -export default function CreateWorkoutSessionScreen() { - const router = useRouter(); - const dispatch = useAppDispatch(); - const { plans, loading: plansLoading } = useAppSelector((s) => s.trainingPlan); - - const [sessionName, setSessionName] = useState(''); - const [selectedPlanId, setSelectedPlanId] = useState(null); - const [creating, setCreating] = useState(false); - - useEffect(() => { - dispatch(loadPlans()); - }, [dispatch]); - - // 自动生成会话名称 - useEffect(() => { - if (!sessionName) { - const today = new Date(); - const dateStr = `${today.getMonth() + 1}月${today.getDate()}日`; - setSessionName(`${dateStr}训练`); - } - }, [sessionName]); - - const selectedPlan = plans.find(p => p.id === selectedPlanId); - const goalConfig = selectedPlan?.goal - ? (GOAL_TEXT[selectedPlan.goal] || { title: '训练', color: palette.primary, description: '开始你的训练之旅' }) - : { title: '新建训练', color: palette.primary, description: '选择创建方式' }; - - // 创建自定义会话 - const handleCreateCustomSession = async () => { - if (creating || !sessionName.trim()) return; - - setCreating(true); - try { - await dispatch(createWorkoutSession({ - name: sessionName.trim(), - scheduledDate: dayjs().format('YYYY-MM-DD') - })).unwrap(); - - // 创建成功后跳转到选择动作页面 - router.replace('/training-plan/schedule/select' as any); - } catch (error) { - console.error('创建训练会话失败:', error); - Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试'); - } finally { - setCreating(false); - } - }; - - // 从训练计划创建会话 - const handleCreateFromPlan = async () => { - if (creating || !selectedPlan || !sessionName.trim()) return; - - setCreating(true); - try { - await dispatch(createWorkoutSession({ - name: sessionName.trim(), - trainingPlanId: selectedPlan.id, - scheduledDate: dayjs().format('YYYY-MM-DD') - })).unwrap(); - - // 创建成功后返回到训练记录页面 - router.back(); - } catch (error) { - console.error('创建训练会话失败:', error); - Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试'); - } finally { - setCreating(false); - } - }; - - // 渲染训练计划卡片 - const renderPlanItem = ({ item, index }: { item: any; index: number }) => { - const isSelected = item.id === selectedPlanId; - const planGoalConfig = GOAL_TEXT[item.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' }; - - return ( - - { - setSelectedPlanId(isSelected ? null : item.id); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - activeOpacity={0.9} - > - - - {item.name} - - {planGoalConfig.title} - - - {planGoalConfig.description} - - - - - {isSelected ? ( - - ) : ( - - )} - - - - {item.exercises && item.exercises.length > 0 && ( - - - {item.exercises.length} 个动作 - - - )} - - - ); - }; - - return ( - - {/* 动态背景 */} - - - - router.back()} - withSafeTop={false} - transparent={true} - tone="light" - /> - - - {/* 会话信息设置 */} - - - - 训练会话设置 - - {goalConfig.description} - - - - - {/* 会话名称输入 */} - - 会话名称 - - - - {/* 创建方式选择 */} - - 选择创建方式 - - {/* 自定义会话 */} - - - - - - 自定义会话 - - 创建空的训练会话,然后手动添加动作 - - - - - - {/* 从训练计划导入 */} - - 从训练计划导入 - - 选择一个训练计划,将其动作导入到新会话中 - - - {plansLoading ? ( - - 加载训练计划中... - - ) : plans.length === 0 ? ( - - - 暂无训练计划 - router.push('/training-plan/create' as any)} - > - 创建训练计划 - - - ) : ( - <> - item.id} - renderItem={renderPlanItem} - contentContainerStyle={styles.plansList} - showsVerticalScrollIndicator={false} - scrollEnabled={false} - /> - - {selectedPlan && ( - - - {creating ? '创建中...' : `从 "${selectedPlan.name}" 创建会话`} - - - )} - - )} - - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: 20, - }, - - // 动态背景 - backgroundOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - top: -150, - right: -100, - }, - backgroundOrb2: { - position: 'absolute', - width: 400, - height: 400, - borderRadius: 200, - bottom: -200, - left: -150, - }, - - // 会话信息头部 - sessionHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 16, - marginBottom: 20, - }, - sessionColorIndicator: { - width: 4, - height: 40, - borderRadius: 2, - marginRight: 12, - }, - sessionInfo: { - flex: 1, - }, - sessionTitle: { - fontSize: 18, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - sessionDescription: { - fontSize: 13, - color: '#5E6468', - opacity: 0.8, - }, - - // 输入区域 - inputSection: { - marginBottom: 24, - }, - inputLabel: { - fontSize: 14, - fontWeight: '700', - color: '#192126', - marginBottom: 8, - }, - textInput: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 16, - color: '#192126', - borderWidth: 1, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - - // 创建方式区域 - methodSection: { - flex: 1, - }, - sectionTitle: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 16, - }, - - // 方式卡片 - methodCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - padding: 16, - marginBottom: 16, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - methodIcon: { - marginRight: 12, - }, - methodInfo: { - flex: 1, - }, - methodTitle: { - fontSize: 16, - fontWeight: '700', - color: '#192126', - marginBottom: 4, - }, - methodDescription: { - fontSize: 12, - color: '#6B7280', - lineHeight: 16, - }, - - // 训练计划导入区域 - planImportSection: { - marginTop: 8, - }, - - // 训练计划列表 - plansList: { - marginTop: 16, - marginBottom: 20, - }, - planCard: { - backgroundColor: '#FFFFFF', - borderRadius: 16, - marginBottom: 12, - borderLeftWidth: 4, - shadowColor: '#000', - shadowOpacity: 0.06, - shadowRadius: 8, - shadowOffset: { width: 0, height: 2 }, - elevation: 2, - }, - planCardContent: { - padding: 16, - }, - planHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 8, - }, - planInfo: { - flex: 1, - }, - planName: { - fontSize: 16, - fontWeight: '800', - color: '#192126', - marginBottom: 4, - }, - planGoal: { - fontSize: 12, - fontWeight: '700', - marginBottom: 2, - }, - planStatus: { - marginLeft: 12, - }, - radioButton: { - width: 24, - height: 24, - borderRadius: 12, - borderWidth: 2, - }, - planStats: { - marginTop: 8, - }, - statsText: { - fontSize: 12, - color: '#6B7280', - }, - - // 空状态 - loadingContainer: { - alignItems: 'center', - paddingVertical: 24, - }, - loadingText: { - fontSize: 14, - color: '#6B7280', - }, - emptyPlansContainer: { - alignItems: 'center', - paddingVertical: 32, - }, - emptyPlansText: { - fontSize: 14, - color: '#6B7280', - marginTop: 8, - marginBottom: 16, - }, - createPlanBtn: { - paddingVertical: 10, - paddingHorizontal: 20, - borderRadius: 8, - }, - createPlanBtnText: { - color: '#FFFFFF', - fontSize: 14, - fontWeight: '700', - }, - - // 确认按钮 - confirmBtn: { - paddingVertical: 16, - borderRadius: 12, - alignItems: 'center', - marginTop: 10, - }, - confirmBtnText: { - color: '#FFFFFF', - fontWeight: '800', - fontSize: 16, - }, -}); diff --git a/app/workout/history.tsx b/app/workout/history.tsx index 6112822..d6186b9 100644 --- a/app/workout/history.tsx +++ b/app/workout/history.tsx @@ -16,6 +16,7 @@ import { import { HeaderBar } from '@/components/ui/HeaderBar'; import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail'; import { addHealthPermissionListener, @@ -283,6 +284,8 @@ export default function WorkoutHistoryScreen() { const [monthOccurrenceText, setMonthOccurrenceText] = useState(null); const [monthlyStats, setMonthlyStats] = useState(null); + const safeAreaTop = useSafeAreaTop() + const loadHistory = useCallback(async () => { setIsLoading(true); setError(null); @@ -532,7 +535,7 @@ export default function WorkoutHistoryScreen() { colors={["#F3F5FF", "#FFFFFF"]} style={StyleSheet.absoluteFill} /> - + {isLoading ? ( @@ -540,7 +543,9 @@ export default function WorkoutHistoryScreen() { ) : ( item.id} renderItem={renderItem} diff --git a/app/workout/notification-settings.tsx b/app/workout/notification-settings.tsx index 8e4ddf1..c4996df 100644 --- a/app/workout/notification-settings.tsx +++ b/app/workout/notification-settings.tsx @@ -1,4 +1,5 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; +import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { getWorkoutNotificationPreferences, resetWorkoutNotificationPreferences, @@ -30,6 +31,7 @@ const WORKOUT_TYPES = [ ]; export default function WorkoutNotificationSettingsScreen() { + const safeAreaTop = useSafeAreaTop() const router = useRouter(); const [preferences, setPreferences] = useState({ enabled: true, @@ -152,6 +154,9 @@ export default function WorkoutNotificationSettingsScreen() { return ( router.back()} /> + 加载中... @@ -163,7 +168,11 @@ export default function WorkoutNotificationSettingsScreen() { router.back()} /> - + {/* 主开关 */} diff --git a/components/WorkoutSummaryCard.tsx b/components/WorkoutSummaryCard.tsx index c4c17c9..a97b2ba 100644 --- a/components/WorkoutSummaryCard.tsx +++ b/components/WorkoutSummaryCard.tsx @@ -15,8 +15,7 @@ import { getWorkoutTypeDisplayName, HealthPermissionStatus, removeHealthPermissionListener, - WorkoutActivityType, - WorkoutData, + WorkoutData } from '@/utils/health'; import { logger } from '@/utils/logger'; @@ -39,21 +38,6 @@ const DEFAULT_SUMMARY: WorkoutSummary = { lastWorkout: null, }; -const iconByWorkoutType: Partial> = { - [WorkoutActivityType.Running]: 'run', - [WorkoutActivityType.Walking]: 'walk', - [WorkoutActivityType.Cycling]: 'bike', - [WorkoutActivityType.Swimming]: 'swim', - [WorkoutActivityType.Yoga]: 'meditation', - [WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter', - [WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell', - [WorkoutActivityType.CrossTraining]: 'arm-flex', - [WorkoutActivityType.MixedCardio]: 'heart-pulse', - [WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast', - [WorkoutActivityType.Flexibility]: 'meditation', - [WorkoutActivityType.Cooldown]: 'meditation', - [WorkoutActivityType.Other]: 'arm-flex', -}; export const WorkoutSummaryCard: React.FC = ({ date, style }) => { const router = useRouter(); @@ -86,34 +70,36 @@ export const WorkoutSummaryCard: React.FC = ({ date, st return; } - const startDate = dayjs(targetDate).startOf('day').toDate(); + // 修改:获取从过去30天到选中日期之间的运动记录 + const startDate = dayjs(targetDate).subtract(30, 'day').startOf('day').toDate(); const endDate = dayjs(targetDate).endOf('day').toDate(); - const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 50); + const workouts = await fetchWorkoutsForDateRange(startDate, endDate, 1); - console.log('workouts', workouts); - - - const workoutsInRange = workouts + // 筛选出选中日期及以前的运动记录,并按结束时间排序(最新在前) + const workoutsBeforeDate = workouts .filter((workout) => { - // 额外防护:确保锻炼记录确实落在当天 + // 确保锻炼记录在选中日期或之前 const workoutDate = dayjs(workout.startDate); - return workoutDate.isSame(dayjs(targetDate), 'day'); + return workoutDate.isSameOrBefore(dayjs(targetDate), 'day'); }) // 依据结束时间排序,最新在前 .sort((a, b) => dayjs(b.endDate || b.startDate).valueOf() - dayjs(a.endDate || a.startDate).valueOf()); - const totalCalories = workoutsInRange.reduce((total, workout) => total + (workout.totalEnergyBurned || 0), 0); - const totalMinutes = Math.round( - workoutsInRange.reduce((total, workout) => total + (workout.duration || 0), 0) / 60 - ); + // 只获取最近的一次运动记录 + const lastWorkout = workoutsBeforeDate.length > 0 ? workoutsBeforeDate[0] : null; - const lastWorkout = workoutsInRange.length > 0 ? workoutsInRange[0] : null; + // 如果有最近一次运动记录,只使用这一条记录来计算总卡路里和总分钟数 + const totalCalories = lastWorkout ? (lastWorkout.totalEnergyBurned || 0) : 0; + const totalMinutes = lastWorkout ? Math.round((lastWorkout.duration || 0) / 60) : 0; + + // 只包含最近一次运动记录 + const recentWorkouts = lastWorkout ? [lastWorkout] : []; if (isMountedRef.current) { setSummary({ totalCalories, totalMinutes, - workouts: workoutsInRange, + workouts: recentWorkouts, lastWorkout, }); setResetToken((token) => token + 1); @@ -153,10 +139,6 @@ export const WorkoutSummaryCard: React.FC = ({ date, st router.push('/workout/history'); }, [router]); - const handleAddPress = useCallback(() => { - router.push('/workout/create-session'); - }, [router]); - const cardContent = useMemo(() => { const hasWorkouts = summary.workouts.length > 0; const lastWorkout = summary.lastWorkout; @@ -213,7 +195,7 @@ export const WorkoutSummaryCard: React.FC = ({ date, st - 健身 + 近期锻炼 diff --git a/components/fasting/FastingStartPickerModal.tsx b/components/fasting/FastingStartPickerModal.tsx index 2721ccd..a2841b4 100644 --- a/components/fasting/FastingStartPickerModal.tsx +++ b/components/fasting/FastingStartPickerModal.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import WheelPickerExpo from 'react-native-wheel-picker-expo'; -import dayjs from 'dayjs'; import { FloatingSelectionCard } from '@/components/ui/FloatingSelectionCard'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import WheelPickerExpo from 'react-native-wheel-picker-expo'; type FastingStartPickerModalProps = { visible: boolean; @@ -119,20 +119,17 @@ export function FastingStartPickerModal({ lastAppliedTimestamp.current = recommendedDate.getTime(); }; - const pickerIndicatorStyle = useMemo( - () => ({ - backgroundColor: `${colors.primary}12`, - borderRadius: 12, - }), - [colors.primary] - ); - const textStyle = { fontSize: 18, fontWeight: '600' as const, color: '#2E3142', }; + // 自定义渲染函数,用于应用文本样式 + const renderItem = ({ fontSize, label, fontColor, textAlign }: { fontSize: number; label: string; fontColor: string; textAlign: 'center' | 'auto' | 'left' | 'right' | 'justify' }) => ( + {label} + ); + return ( ({ label: item.label, value: item.offset }))} onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))} backgroundColor="transparent" - itemTextStyle={textStyle} - selectedIndicatorStyle={pickerIndicatorStyle} + renderItem={renderItem} haptics /> ({ label: hour.toString().padStart(2, '0'), value: hour }))} onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))} backgroundColor="transparent" - itemTextStyle={textStyle} - selectedIndicatorStyle={pickerIndicatorStyle} + renderItem={renderItem} haptics /> setIndexes((prev) => ({ ...prev, minuteIndex: index }))} backgroundColor="transparent" - itemTextStyle={textStyle} - selectedIndicatorStyle={pickerIndicatorStyle} + renderItem={renderItem} haptics /> diff --git a/components/ui/FloatingSelectionCard.tsx b/components/ui/FloatingSelectionCard.tsx index 0538943..e523f37 100644 --- a/components/ui/FloatingSelectionCard.tsx +++ b/components/ui/FloatingSelectionCard.tsx @@ -32,10 +32,10 @@ export function FloatingSelectionCard({ ]; const closeWrapperProps = glassAvailable ? { - glassEffectStyle: 'regular' as const, - tintColor: 'rgba(255,255,255,0.45)', - isInteractive: true, - } + glassEffectStyle: 'regular' as const, + tintColor: 'rgba(255,255,255,0.45)', + isInteractive: true, + } : {}; return ( @@ -136,9 +136,6 @@ const styles = StyleSheet.create({ elevation: 3, }, closeButtonInnerGlass: { - borderWidth: StyleSheet.hairlineWidth, - borderColor: 'rgba(255,255,255,0.45)', - backgroundColor: 'rgba(255,255,255,0.35)', }, closeButtonInnerFallback: { backgroundColor: 'rgba(255, 255, 255, 0.9)', diff --git a/components/ui/HeaderBar.tsx b/components/ui/HeaderBar.tsx index 61240f1..2f1930f 100644 --- a/components/ui/HeaderBar.tsx +++ b/components/ui/HeaderBar.tsx @@ -1,6 +1,7 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { router } from 'expo-router'; import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -58,12 +59,14 @@ export function HeaderBar({ }; }; + const defaultBackColor = 'rgba(0,0,0,0.8)' + return ( - { - if (onBack) { - onBack(); - return - } - router.back() - }} - style={styles.backButton} - activeOpacity={0.7} - > - - + {isLiquidGlassAvailable() ? ( + { + if (onBack) { + onBack(); + return + } + router.back() + }} + activeOpacity={0.7} + > + + + + + ) : ( + { + if (onBack) { + onBack(); + return + } + router.back() + }} + style={[styles.backButton, styles.fallbackBackground]} + activeOpacity={0.7} + > + + + )} {typeof title === 'string' ? ( { + const insets = useSafeAreaInsets(); + + return { + top: insets.top + (extraPadding.top || 40), + bottom: insets.bottom + (extraPadding.bottom || 0), + left: insets.left + (extraPadding.left || 0), + right: insets.right + (extraPadding.right || 0), + }; +}; + +/** + * 获取安全区域顶部距离的 hook + * @param extraPadding 额外的间距,默认为 20 像素 + * @returns 顶部安全区域距离加上额外间距的总值 + */ +export const useSafeAreaTop = (extraPadding: number = 50) => { + const insets = useSafeAreaInsets(); + return insets.top + extraPadding; +}; \ No newline at end of file diff --git a/utils/native.utils.ts b/utils/native.utils.ts index 3629398..2c0db96 100644 --- a/utils/native.utils.ts +++ b/utils/native.utils.ts @@ -5,6 +5,7 @@ export const getStatusBarHeight = () => { return StatusBar.currentHeight; }; + export const getDeviceDimensions = () => { const { width, height } = Dimensions.get('window');