feat: 适配 headerbar ios26
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
# kilo-rule.md
|
# kilo-rule.md
|
||||||
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android
|
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android, 代码设计优美、可读性高
|
||||||
|
|
||||||
## 指导原则
|
## 指导原则
|
||||||
|
|
||||||
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||||
- 不要尝试使用 `npm run ios` 命令
|
- 不要尝试使用 `npm run ios` 命令
|
||||||
|
- 优先使用 Liquid Glass 风格组件
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<PoseView, Sample[]> = {
|
|
||||||
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<UploadState>({});
|
|
||||||
const canStart = useMemo(
|
|
||||||
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
|
|
||||||
[uploadState]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { upload, uploading } = useCosUpload();
|
|
||||||
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
|
|
||||||
|
|
||||||
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
|
||||||
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
|
||||||
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
|
||||||
const [cameraCanAsk, setCameraCanAsk] = useState<boolean | null>(null);
|
|
||||||
const [libraryCanAsk, setLibraryCanAsk] = useState<boolean | null>(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 (
|
|
||||||
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
|
|
||||||
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* Permissions Banner (iOS 优先提示) */}
|
|
||||||
{Platform.OS === 'ios' && (
|
|
||||||
(cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && (
|
|
||||||
<BlurView intensity={18} tint="dark" style={styles.permBanner}>
|
|
||||||
<Text style={styles.permTitle}>需要相机与相册权限</Text>
|
|
||||||
<Text style={styles.permDesc}>
|
|
||||||
授权后可拍摄或选择三视角全身照片用于AI体态测评。
|
|
||||||
</Text>
|
|
||||||
<View style={styles.permActions}>
|
|
||||||
{((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? (
|
|
||||||
<TouchableOpacity style={styles.permPrimary} onPress={requestAllPermissions}>
|
|
||||||
<Text style={styles.permPrimaryText}>一键授权</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity style={styles.permPrimary} onPress={() => Linking.openSettings()}>
|
|
||||||
<Text style={styles.permPrimaryText}>去设置开启</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity style={styles.permSecondary} onPress={() => requestPermissionAndPick('library', 'front')}>
|
|
||||||
<Text style={styles.permSecondaryText}>稍后再说</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Intro */}
|
|
||||||
<View style={styles.introBox}>
|
|
||||||
<Text style={[styles.title, { color: '#192126' }]}>上传标准姿势照片</Text>
|
|
||||||
<Text style={[styles.description, { color: '#5E6468' }]}>请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Upload sections */}
|
|
||||||
<UploadTile
|
|
||||||
label="正面"
|
|
||||||
value={uploadState.front}
|
|
||||||
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
|
||||||
samples={SAMPLES.front}
|
|
||||||
uploading={uploading && uploadingKey === 'front'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UploadTile
|
|
||||||
label="侧面"
|
|
||||||
value={uploadState.side}
|
|
||||||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
|
||||||
samples={SAMPLES.side}
|
|
||||||
uploading={uploading && uploadingKey === 'side'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UploadTile
|
|
||||||
label="背面"
|
|
||||||
value={uploadState.back}
|
|
||||||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
|
||||||
samples={SAMPLES.back}
|
|
||||||
uploading={uploading && uploadingKey === 'back'}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Bottom CTA */}
|
|
||||||
<View style={[styles.bottomCtaWrap, { paddingBottom: insets.bottom + 10 }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={!canStart}
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={handleStart}
|
|
||||||
style={[
|
|
||||||
styles.bottomCta,
|
|
||||||
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
|
|
||||||
{canStart ? '开始测评' : '请先完成三视角上传'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>{label}</Text>
|
|
||||||
{value ? (
|
|
||||||
<Text style={styles.retakeHint}>可长按替换</Text>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.retakeHint}>需上传此视角</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.95}
|
|
||||||
onLongPress={onPickLibrary}
|
|
||||||
onPress={onPickCamera}
|
|
||||||
style={styles.uploader}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
|
|
||||||
<ActivityIndicator size="large" color={Colors.light.accentGreen} />
|
|
||||||
<Text style={styles.placeholderTitle}>上传中...</Text>
|
|
||||||
</View>
|
|
||||||
) : value ? (
|
|
||||||
<Image source={{ uri: value }} style={styles.preview} />
|
|
||||||
) : (
|
|
||||||
<View style={styles.placeholder}>
|
|
||||||
<View style={styles.plusBadge}>
|
|
||||||
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
|
||||||
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
|
|
||||||
<Text style={styles.sampleTitle}>示例</Text>
|
|
||||||
<View style={styles.sampleRow}>
|
|
||||||
{samples.map((s, idx) => (
|
|
||||||
<View key={idx} style={styles.sampleItem}>
|
|
||||||
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
|
|
||||||
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
|
|
||||||
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
<ImageViewing
|
|
||||||
images={imagesForViewer}
|
|
||||||
imageIndex={viewerIndex}
|
|
||||||
visible={viewerVisible}
|
|
||||||
onRequestClose={() => setViewerVisible(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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 (
|
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
|
||||||
<HeaderBar title="AI评估进行中" onBack={() => router.back()} tone="light" transparent />
|
|
||||||
|
|
||||||
{/* Layered background */}
|
|
||||||
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#F7FFE8", "#F0FBFF", "#FFF6E8"]}
|
|
||||||
start={{ x: 0.1, y: 0 }}
|
|
||||||
end={{ x: 0.9, y: 1 }}
|
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
/>
|
|
||||||
<BlurView intensity={20} tint="light" style={styles.blurBlobA} />
|
|
||||||
<BlurView intensity={20} tint="light" style={styles.blurBlobB} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Hero visualization */}
|
|
||||||
<View style={styles.hero}>
|
|
||||||
<View style={styles.heroBackdrop} />
|
|
||||||
<Animated.View style={[styles.ringOuter, ringStyleOuter]} />
|
|
||||||
<Animated.View style={[styles.ringInner, ringStyleInner]} />
|
|
||||||
|
|
||||||
<View style={styles.grid}>
|
|
||||||
{Array.from({ length: 9 }).map((_, i) => (
|
|
||||||
<View key={`row-${i}`} style={styles.gridRow}>
|
|
||||||
{Array.from({ length: 9 }).map((__, j) => (
|
|
||||||
<View key={`cell-${i}-${j}`} style={styles.gridCell} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<Animated.View style={[styles.scanner, scannerStyle]} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View style={[styles.particleA, particleStyleA]} />
|
|
||||||
<Animated.View style={[styles.particleB, particleStyleA, { right: undefined, left: SCREEN_WIDTH * 0.2, top: undefined, bottom: 60 }]} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Copy & actions */}
|
|
||||||
<View style={[styles.panel, { paddingBottom: insets.bottom + 16 }]}>
|
|
||||||
<Text style={styles.title}>正在进行体态特征提取与矢量评估</Text>
|
|
||||||
<Text style={styles.subtitle}>这通常需要 10-30 秒。你可以停留在此页面等待结果,或点击返回稍后在个人中心查看。</Text>
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.primaryBtn, { backgroundColor: theme.primary }]}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
// TODO: 评估完成后恢复为停留当前页面等待结果(不要跳转)
|
|
||||||
onPress={() => router.replace('/ai-posture-result')}
|
|
||||||
>
|
|
||||||
<Ionicons name="time-outline" size={16} color="#192126" />
|
|
||||||
<Text style={styles.primaryBtnText}>保持页面等待</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.secondaryBtn} activeOpacity={0.9} onPress={() => router.replace('/(tabs)/personal')}>
|
|
||||||
<Text style={styles.secondaryBtnText}>返回个人中心</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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<PoseView, ViewReport>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 }) => (
|
|
||||||
<View style={styles.scoreBadge}>
|
|
||||||
<Text style={styles.scoreText}>{score.toFixed(1)}</Text>
|
|
||||||
<Text style={styles.scoreUnit}>/5</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const IssueItem = ({ issue }: { issue: Issue }) => (
|
|
||||||
<View style={styles.issueItem}>
|
|
||||||
<View style={[styles.issueDot, issue.severity === 'high' ? styles.dotHigh : issue.severity === 'medium' ? styles.dotMedium : styles.dotLow]} />
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.issueTitle}>{issue.title}</Text>
|
|
||||||
<Text style={styles.issueDesc}>{issue.description}</Text>
|
|
||||||
{!!issue.suggestions?.length && (
|
|
||||||
<View style={styles.suggestRow}>
|
|
||||||
{issue.suggestions.map((s, idx) => (
|
|
||||||
<View key={idx} style={styles.suggestChip}><Text style={styles.suggestText}>{s}</Text></View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ViewCard = ({ title, report }: { title: string; report: ViewReport }) => (
|
|
||||||
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
|
||||||
<View style={styles.cardHeader}>
|
|
||||||
<Text style={styles.cardTitle}>{title}</Text>
|
|
||||||
<ScoreBadge score={report.score} />
|
|
||||||
</View>
|
|
||||||
{report.issues.map((iss, idx) => (<IssueItem key={idx} issue={iss} />))}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
|
||||||
<HeaderBar title="体态评估结果" onBack={() => router.back()} tone="light" transparent />
|
|
||||||
|
|
||||||
{/* 背景装饰 */}
|
|
||||||
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
|
||||||
<BlurView intensity={20} tint="light" style={styles.bgBlobA} />
|
|
||||||
<BlurView intensity={20} tint="light" style={styles.bgBlobB} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 40 }} showsVerticalScrollIndicator={false}>
|
|
||||||
{/* 总览与雷达图 */}
|
|
||||||
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
|
||||||
<Text style={styles.sectionTitle}>总体概览</Text>
|
|
||||||
<Text style={styles.overview}>{MOCK_RESULT.overview}</Text>
|
|
||||||
<View style={styles.radarWrap}>
|
|
||||||
<RadarChart categories={categories} values={MOCK_RESULT.radar} />
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* 视图分析 */}
|
|
||||||
<ViewCard title="正面视图" report={MOCK_RESULT.byView.front} />
|
|
||||||
<ViewCard title="侧面视图" report={MOCK_RESULT.byView.side} />
|
|
||||||
<ViewCard title="背面视图" report={MOCK_RESULT.byView.back} />
|
|
||||||
|
|
||||||
{/* 底部操作 */}
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: theme.primary }]} onPress={() => router.replace('/(tabs)/personal')}>
|
|
||||||
<Ionicons name="checkmark-circle" size={18} color={theme.onPrimary} />
|
|
||||||
<Text style={[styles.primaryBtnText, { color: theme.onPrimary }]}>完成并返回</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/(tabs)/coach')}>
|
|
||||||
<Text style={[styles.secondaryBtnText, { color: theme.text }]}>生成训练建议</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { DateSelector } from '@/components/DateSelector';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||||
@@ -25,6 +26,7 @@ type BasalMetabolismData = {
|
|||||||
export default function BasalMetabolismDetailScreen() {
|
export default function BasalMetabolismDetailScreen() {
|
||||||
const userProfile = useAppSelector(selectUserProfile);
|
const userProfile = useAppSelector(selectUserProfile);
|
||||||
const userAge = useAppSelector(selectUserAge);
|
const userAge = useAppSelector(selectUserAge);
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
// 日期相关状态
|
// 日期相关状态
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
@@ -329,11 +331,14 @@ export default function BasalMetabolismDetailScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 60,
|
paddingBottom: 60,
|
||||||
paddingHorizontal: 20
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: safeAreaTop
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -362,7 +362,6 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title=""
|
title=""
|
||||||
backColor="white"
|
|
||||||
tone="light"
|
tone="light"
|
||||||
transparent
|
transparent
|
||||||
withSafeTop={false}
|
withSafeTop={false}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import {
|
import {
|
||||||
fetchChallengeDetail,
|
fetchChallengeDetail,
|
||||||
fetchChallengeRankings,
|
fetchChallengeRankings,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function ChallengeLeaderboardScreen() {
|
export default function ChallengeLeaderboardScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const { id } = useLocalSearchParams<{ id?: string }>();
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -74,6 +76,9 @@ export default function ChallengeLeaderboardScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.missingContainer}>
|
<View style={styles.missingContainer}>
|
||||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战。</Text>
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战。</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -144,7 +149,7 @@ export default function ChallengeLeaderboardScreen() {
|
|||||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
|
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ const CIRCUMFERENCE_TYPES = [
|
|||||||
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
||||||
|
|
||||||
type TabType = CircumferencePeriod;
|
type TabType = CircumferencePeriod;
|
||||||
|
|
||||||
export default function CircumferenceDetailScreen() {
|
export default function CircumferenceDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const userProfile = useAppSelector(selectUserProfile);
|
const userProfile = useAppSelector(selectUserProfile);
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
@@ -293,7 +295,8 @@ export default function CircumferenceDetailScreen() {
|
|||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 60,
|
paddingBottom: 60,
|
||||||
paddingHorizontal: 20
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: safeAreaTop
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import {
|
import {
|
||||||
fetchActivityRingsForDate,
|
fetchActivityRingsForDate,
|
||||||
fetchHourlyActiveCaloriesForDate,
|
fetchHourlyActiveCaloriesForDate,
|
||||||
@@ -50,6 +51,7 @@ type WeekData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FitnessRingsDetailScreen() {
|
export default function FitnessRingsDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
@@ -511,7 +513,9 @@ export default function FitnessRingsDetailScreen() {
|
|||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 本周圆环横向滚动 */}
|
{/* 本周圆环横向滚动 */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
|
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords';
|
import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords';
|
||||||
import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi';
|
import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi';
|
||||||
import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
||||||
@@ -36,6 +37,7 @@ const MEAL_TYPE_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FoodLibraryScreen() {
|
export default function FoodLibraryScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||||
const mealType = (params.mealType as MealType) || 'breakfast';
|
const mealType = (params.mealType as MealType) || 'breakfast';
|
||||||
@@ -272,7 +274,6 @@ export default function FoodLibraryScreen() {
|
|||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="食物库"
|
title="食物库"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
transparent={false}
|
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
right={
|
right={
|
||||||
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
|
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
|
||||||
@@ -281,6 +282,10 @@ export default function FoodLibraryScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
|
|
||||||
{/* 搜索框 */}
|
{/* 搜索框 */}
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
|
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
||||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -73,6 +74,7 @@ const MEAL_TYPE_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FoodAnalysisResultScreen() {
|
export default function FoodAnalysisResultScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
imageUri?: string;
|
imageUri?: string;
|
||||||
@@ -264,6 +266,9 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
title="分析结果"
|
title="分析结果"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -287,7 +292,9 @@ export default function FoodAnalysisResultScreen() {
|
|||||||
transparent={true}
|
transparent={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scrollContainer} contentContainerStyle={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} showsVerticalScrollIndicator={false}>
|
||||||
{/* 食物主图 */}
|
{/* 食物主图 */}
|
||||||
<View style={styles.imageContainer}>
|
<View style={styles.imageContainer}>
|
||||||
{imageUri ? (
|
{imageUri ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
@@ -8,21 +9,18 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Dimensions,
|
|
||||||
Modal,
|
Modal,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
|
||||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
|
||||||
|
|
||||||
export default function FoodCameraScreen() {
|
export default function FoodCameraScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||||
const cameraRef = useRef<CameraView>(null);
|
const cameraRef = useRef<CameraView>(null);
|
||||||
@@ -45,28 +43,34 @@ export default function FoodCameraScreen() {
|
|||||||
if (!permission) {
|
if (!permission) {
|
||||||
// 权限仍在加载中
|
// 权限仍在加载中
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={styles.container}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="食物拍摄"
|
title="食物拍摄"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<Text style={styles.loadingText}>正在加载相机...</Text>
|
<Text style={styles.loadingText}>正在加载相机...</Text>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!permission.granted) {
|
if (!permission.granted) {
|
||||||
// 没有相机权限
|
// 没有相机权限
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={styles.container}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="食物拍摄"
|
title="食物拍摄"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
backColor='#ffffff'
|
backColor='#ffffff'
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
<Ionicons name="camera-outline" size={64} color="#999" />
|
<Ionicons name="camera-outline" size={64} color="#999" />
|
||||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||||
@@ -80,7 +84,7 @@ export default function FoodCameraScreen() {
|
|||||||
<Text style={styles.permissionButtonText}>授权访问</Text>
|
<Text style={styles.permissionButtonText}>授权访问</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +156,9 @@ export default function FoodCameraScreen() {
|
|||||||
transparent={true}
|
transparent={true}
|
||||||
backColor={'#fff'}
|
backColor={'#fff'}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
{/* 主要内容区域 */}
|
{/* 主要内容区域 */}
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{/* 取景框容器 */}
|
{/* 取景框容器 */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { recognizeFood } from '@/services/foodRecognition';
|
import { recognizeFood } from '@/services/foodRecognition';
|
||||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function FoodRecognitionScreen() {
|
export default function FoodRecognitionScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
imageUri?: string;
|
imageUri?: string;
|
||||||
@@ -217,6 +219,9 @@ export default function FoodRecognitionScreen() {
|
|||||||
title="食物识别"
|
title="食物识别"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Text style={styles.errorText}>未找到图片</Text>
|
<Text style={styles.errorText}>未找到图片</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -232,7 +237,9 @@ export default function FoodRecognitionScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
{/* 主要内容区域 */}
|
||||||
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.contentContainer} contentContainerStyle={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} showsVerticalScrollIndicator={false}>
|
||||||
{!showRecognitionProcess ? (
|
{!showRecognitionProcess ? (
|
||||||
// 确认界面
|
// 确认界面
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={backgroundGradientColors}
|
|
||||||
style={styles.gradientBackground}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 0, y: 1 }}
|
|
||||||
/>
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.centerContainer}>
|
|
||||||
<Text style={styles.loginPrompt}>请先登录查看心情统计</Text>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={backgroundGradientColors}
|
|
||||||
style={styles.gradientBackground}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 0, y: 1 }}
|
|
||||||
/>
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<HeaderBar
|
|
||||||
title="心情统计"
|
|
||||||
onBack={() => router.back()}
|
|
||||||
withSafeTop={false}
|
|
||||||
transparent={true}
|
|
||||||
tone="light"
|
|
||||||
/>
|
|
||||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
|
||||||
|
|
||||||
{loading.history || loading.statistics ? (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color={colorTokens.primary} />
|
|
||||||
<Text style={styles.loadingText}>加载中...</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 统计概览 */}
|
|
||||||
{statistics && (
|
|
||||||
<View style={styles.statsOverview}>
|
|
||||||
<Text style={styles.sectionTitle}>统计概览</Text>
|
|
||||||
<View style={styles.statsGrid}>
|
|
||||||
<View style={styles.statCard}>
|
|
||||||
<Text style={styles.statNumber}>{statistics.totalCheckins}</Text>
|
|
||||||
<Text style={styles.statLabel}>总打卡次数</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statCard}>
|
|
||||||
<Text style={styles.statNumber}>{statistics.averageIntensity.toFixed(1)}</Text>
|
|
||||||
<Text style={styles.statLabel}>平均强度</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statCard}>
|
|
||||||
<Text style={styles.statNumber}>
|
|
||||||
{statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>最常见心情</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 心情历史记录 */}
|
|
||||||
<MoodHistoryCard
|
|
||||||
moodCheckins={moodCheckins}
|
|
||||||
title="最近30天心情记录"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 心情分布 */}
|
|
||||||
{statistics && (
|
|
||||||
<View style={styles.distributionContainer}>
|
|
||||||
<Text style={styles.sectionTitle}>心情分布</Text>
|
|
||||||
<View style={styles.distributionList}>
|
|
||||||
{Object.entries(statistics.moodDistribution)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.map(([moodType, count]) => (
|
|
||||||
<View key={moodType} style={styles.distributionItem}>
|
|
||||||
<Text style={styles.moodType}>{moodType}</Text>
|
|
||||||
<View style={styles.countContainer}>
|
|
||||||
<Text style={styles.count}>{count}</Text>
|
|
||||||
<Text style={styles.percentage}>
|
|
||||||
({((count / statistics.totalCheckins) * 100).toFixed(1)}%)
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
import { useMoodData } from '@/hooks/useMoodData';
|
import { useMoodData } from '@/hooks/useMoodData';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { getMoodOptions } from '@/services/moodCheckins';
|
import { getMoodOptions } from '@/services/moodCheckins';
|
||||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -8,7 +9,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dimensions, Image, SafeAreaView,
|
Dimensions, Image,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -60,6 +61,7 @@ const generateCalendarData = (targetDate: Date) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MoodCalendarScreen() {
|
export default function MoodCalendarScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ export default function MoodCalendarScreen() {
|
|||||||
<View style={styles.decorativeCircle1} />
|
<View style={styles.decorativeCircle1} />
|
||||||
<View style={styles.decorativeCircle2} />
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="心情日历"
|
title="心情日历"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
@@ -240,7 +242,9 @@ export default function MoodCalendarScreen() {
|
|||||||
tone="light"
|
tone="light"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView style={styles.content}>
|
<ScrollView style={styles.content} contentContainerStyle={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}}>
|
||||||
{/* 日历视图 */}
|
{/* 日历视图 */}
|
||||||
<View style={styles.calendar}>
|
<View style={styles.calendar}>
|
||||||
{/* 月份导航 */}
|
{/* 月份导航 */}
|
||||||
@@ -363,7 +367,7 @@ export default function MoodCalendarScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
||||||
import {
|
import {
|
||||||
createMoodRecord,
|
createMoodRecord,
|
||||||
@@ -28,9 +29,10 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function MoodEditScreen() {
|
export default function MoodEditScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -179,7 +181,7 @@ export default function MoodEditScreen() {
|
|||||||
{/* 装饰性圆圈 */}
|
{/* 装饰性圆圈 */}
|
||||||
<View style={styles.decorativeCircle1} />
|
<View style={styles.decorativeCircle1} />
|
||||||
<View style={styles.decorativeCircle2} />
|
<View style={styles.decorativeCircle2} />
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
<View style={styles.safeArea} >
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title={existingMood ? '编辑心情' : '记录心情'}
|
title={existingMood ? '编辑心情' : '记录心情'}
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
@@ -196,81 +198,83 @@ export default function MoodEditScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 日期显示 */}
|
{/* 日期显示 */}
|
||||||
<View style={styles.dateSection}>
|
<View style={styles.dateSection}>
|
||||||
<Text style={styles.dateTitle}>
|
<Text style={styles.dateTitle}>
|
||||||
{dayjs(selectedDate).format('YYYY年M月D日')}
|
{dayjs(selectedDate).format('YYYY年M月D日')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 心情选择 */}
|
|
||||||
<View style={styles.moodSection}>
|
|
||||||
<Text style={styles.sectionTitle}>选择心情</Text>
|
|
||||||
<View style={styles.moodOptions}>
|
|
||||||
{moodOptions.map((mood, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={index}
|
|
||||||
style={[
|
|
||||||
styles.moodOption,
|
|
||||||
selectedMood === mood.type && styles.selectedMoodOption
|
|
||||||
]}
|
|
||||||
onPress={() => setSelectedMood(mood.type)}
|
|
||||||
>
|
|
||||||
<Image source={mood.image} style={styles.moodImage} />
|
|
||||||
<Text style={styles.moodLabel}>{mood.label}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 心情强度选择 */}
|
{/* 心情选择 */}
|
||||||
<View style={styles.intensitySection}>
|
<View style={styles.moodSection}>
|
||||||
<Text style={styles.sectionTitle}>心情强度</Text>
|
<Text style={styles.sectionTitle}>选择心情</Text>
|
||||||
<MoodIntensitySlider
|
<View style={styles.moodOptions}>
|
||||||
value={intensity}
|
{moodOptions.map((mood, index) => (
|
||||||
onValueChange={handleIntensityChange}
|
<TouchableOpacity
|
||||||
min={1}
|
key={index}
|
||||||
max={10}
|
style={[
|
||||||
width={280}
|
styles.moodOption,
|
||||||
height={12}
|
selectedMood === mood.type && styles.selectedMoodOption
|
||||||
/>
|
]}
|
||||||
</View>
|
onPress={() => setSelectedMood(mood.type)}
|
||||||
|
>
|
||||||
|
<Image source={mood.image} style={styles.moodImage} />
|
||||||
|
<Text style={styles.moodLabel}>{mood.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 心情强度选择 */}
|
||||||
|
<View style={styles.intensitySection}>
|
||||||
|
<Text style={styles.sectionTitle}>心情强度</Text>
|
||||||
|
<MoodIntensitySlider
|
||||||
|
value={intensity}
|
||||||
|
onValueChange={handleIntensityChange}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
width={280}
|
||||||
|
height={12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
{/* 心情描述 */}
|
{/* 心情描述 */}
|
||||||
|
|
||||||
<View style={styles.descriptionSection}>
|
<View style={styles.descriptionSection}>
|
||||||
<Text style={styles.sectionTitle}>心情日记</Text>
|
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||||
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={textInputRef}
|
ref={textInputRef}
|
||||||
style={styles.descriptionInput}
|
style={styles.descriptionInput}
|
||||||
placeholder={`今天的心情如何?
|
placeholder={`今天的心情如何?
|
||||||
|
|
||||||
你经历过什么特别的事情吗?
|
你经历过什么特别的事情吗?
|
||||||
有什么让你开心的事?
|
有什么让你开心的事?
|
||||||
或者,有什么让你感到困扰?
|
或者,有什么让你感到困扰?
|
||||||
|
|
||||||
写下你的感受,让这些时刻成为你珍贵的记忆...`}
|
写下你的感受,让这些时刻成为你珍贵的记忆...`}
|
||||||
placeholderTextColor="#a8a8a8"
|
placeholderTextColor="#a8a8a8"
|
||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={setDescription}
|
||||||
multiline
|
multiline
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
// 当文本输入框获得焦点时,滚动到输入框
|
// 当文本输入框获得焦点时,滚动到输入框
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -299,7 +303,7 @@ export default function MoodEditScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { DietRecord } from '@/services/dietRecords';
|
import { DietRecord } from '@/services/dietRecords';
|
||||||
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||||
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
|
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
type ViewMode = 'daily' | 'all';
|
type ViewMode = 'daily' | 'all';
|
||||||
|
|
||||||
export default function NutritionRecordsScreen() {
|
export default function NutritionRecordsScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -425,46 +427,52 @@ export default function NutritionRecordsScreen() {
|
|||||||
right={renderRightButton()}
|
right={renderRightButton()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* {renderViewModeToggle()} */}
|
<View style={{
|
||||||
{renderDateSelector()}
|
paddingTop: safeAreaTop
|
||||||
|
}}>
|
||||||
|
|
||||||
{/* Calorie Ring Chart */}
|
{/* {renderViewModeToggle()} */}
|
||||||
<CalorieRingChart
|
{renderDateSelector()}
|
||||||
metabolism={basalMetabolism}
|
|
||||||
exercise={healthData?.activeEnergyBurned || 0}
|
|
||||||
consumed={nutritionSummary?.totalCalories || 0}
|
|
||||||
protein={nutritionSummary?.totalProtein || 0}
|
|
||||||
fat={nutritionSummary?.totalFat || 0}
|
|
||||||
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
|
||||||
proteinGoal={nutritionGoals.proteinGoal}
|
|
||||||
fatGoal={nutritionGoals.fatGoal}
|
|
||||||
carbsGoal={nutritionGoals.carbsGoal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(
|
{/* Calorie Ring Chart */}
|
||||||
<FlatList
|
<CalorieRingChart
|
||||||
data={displayRecords}
|
metabolism={basalMetabolism}
|
||||||
renderItem={({ item, index }) => renderRecord({ item, index })}
|
exercise={healthData?.activeEnergyBurned || 0}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
consumed={nutritionSummary?.totalCalories || 0}
|
||||||
contentContainerStyle={[
|
protein={nutritionSummary?.totalProtein || 0}
|
||||||
styles.listContainer,
|
fat={nutritionSummary?.totalFat || 0}
|
||||||
{ paddingBottom: 40, paddingTop: 16 }
|
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
||||||
]}
|
proteinGoal={nutritionGoals.proteinGoal}
|
||||||
showsVerticalScrollIndicator={false}
|
fatGoal={nutritionGoals.fatGoal}
|
||||||
refreshControl={
|
carbsGoal={nutritionGoals.carbsGoal}
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
tintColor={colorTokens.primary}
|
|
||||||
colors={[colorTokens.primary]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
ListEmptyComponent={renderEmptyState}
|
|
||||||
ListFooterComponent={renderFooter}
|
|
||||||
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
|
||||||
onEndReachedThreshold={0.1}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
{(
|
||||||
|
<FlatList
|
||||||
|
data={displayRecords}
|
||||||
|
renderItem={({ item, index }) => renderRecord({ item, index })}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.listContainer,
|
||||||
|
{ paddingBottom: 40, paddingTop: 16 }
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colorTokens.primary}
|
||||||
|
colors={[colorTokens.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 食物添加悬浮窗 */}
|
{/* 食物添加悬浮窗 */}
|
||||||
<FloatingFoodOverlay
|
<FloatingFoodOverlay
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||||
import { fetchMaximumHeartRate } from '@/utils/health';
|
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
@@ -21,14 +22,12 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +46,7 @@ interface UserProfile {
|
|||||||
const STORAGE_KEY = '@user_profile';
|
const STORAGE_KEY = '@user_profile';
|
||||||
|
|
||||||
export default function EditProfileScreen() {
|
export default function EditProfileScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -287,10 +287,7 @@ export default function EditProfileScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
<View style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||||
<StatusBar barStyle={'dark-content'} />
|
|
||||||
|
|
||||||
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="编辑资料"
|
title="编辑资料"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
@@ -300,7 +297,7 @@ export default function EditProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 40, paddingTop: safeAreaTop }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 32 }}>
|
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 32 }}>
|
||||||
@@ -504,7 +501,7 @@ export default function EditProfileScreen() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ import * as Haptics from 'expo-haptics';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { ProgressBar } from '@/components/ProgressBar';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { updateUser as updateUserApi } from '@/services/users';
|
import { updateUser as updateUserApi } from '@/services/users';
|
||||||
import { fetchMyProfile } from '@/store/userSlice';
|
import { fetchMyProfile } from '@/store/userSlice';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
@@ -43,6 +43,7 @@ function arraysEqualUnordered(a?: string[], b?: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GoalsScreen() {
|
export default function GoalsScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
@@ -135,7 +136,7 @@ export default function GoalsScreen() {
|
|||||||
lastSentRef.current.calories = calories;
|
lastSentRef.current.calories = calories;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await updateUserApi({ userId, dailyCaloriesGoal: calories });
|
await updateUserApi({ dailyCaloriesGoal: calories });
|
||||||
await dispatch(fetchMyProfile() as any);
|
await dispatch(fetchMyProfile() as any);
|
||||||
} catch { }
|
} catch { }
|
||||||
})();
|
})();
|
||||||
@@ -148,7 +149,7 @@ export default function GoalsScreen() {
|
|||||||
lastSentRef.current.steps = steps;
|
lastSentRef.current.steps = steps;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await updateUserApi({ userId, dailyStepsGoal: steps });
|
await updateUserApi({ dailyStepsGoal: steps });
|
||||||
await dispatch(fetchMyProfile() as any);
|
await dispatch(fetchMyProfile() as any);
|
||||||
} catch { }
|
} catch { }
|
||||||
})();
|
})();
|
||||||
@@ -161,7 +162,7 @@ export default function GoalsScreen() {
|
|||||||
lastSentRef.current.purposes = [...purposes];
|
lastSentRef.current.purposes = [...purposes];
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await updateUserApi({ userId, pilatesPurposes: purposes });
|
await updateUserApi({ pilatesPurposes: purposes });
|
||||||
await dispatch(fetchMyProfile() as any);
|
await dispatch(fetchMyProfile() as any);
|
||||||
} catch { }
|
} catch { }
|
||||||
})();
|
})();
|
||||||
@@ -245,9 +246,9 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
||||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16), paddingTop: safeAreaTop }]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
|
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
|
||||||
@@ -305,7 +306,7 @@ export default function GoalsScreen() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
|
||||||
// SleepGradeCard 组件现在在 InfoModal 组件内部
|
// SleepGradeCard 组件现在在 InfoModal 组件内部
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
// InfoModal 组件现在从独立文件导入
|
// InfoModal 组件现在从独立文件导入
|
||||||
|
|
||||||
export default function SleepDetailScreen() {
|
export default function SleepDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
||||||
@@ -123,7 +126,9 @@ export default function SleepDetailScreen() {
|
|||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 睡眠得分圆形显示 */}
|
{/* 睡眠得分圆形显示 */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
export default function StepsDetailScreen() {
|
export default function StepsDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
// 获取路由参数
|
// 获取路由参数
|
||||||
const { date } = useLocalSearchParams<{ date?: string }>();
|
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||||
|
|
||||||
@@ -213,7 +216,9 @@ export default function StepsDetailScreen() {
|
|||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{}}
|
contentContainerStyle={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 日期选择器 */}
|
{/* 日期选择器 */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
export default function TaskDetailScreen() {
|
export default function TaskDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const { taskId } = useLocalSearchParams<{ taskId: string }>();
|
const { taskId } = useLocalSearchParams<{ taskId: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme() ?? 'light';
|
||||||
@@ -182,6 +184,9 @@ export default function TaskDetailScreen() {
|
|||||||
title="任务详情"
|
title="任务详情"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<Text style={[styles.loadingText, { color: colorTokens.text }]}>加载中...</Text>
|
<Text style={[styles.loadingText, { color: colorTokens.text }]}>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -196,6 +201,9 @@ export default function TaskDetailScreen() {
|
|||||||
title="任务详情"
|
title="任务详情"
|
||||||
onBack={() => router.back()}
|
onBack={() => router.back()}
|
||||||
/>
|
/>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Text style={[styles.errorText, { color: colorTokens.text }]}>任务不存在</Text>
|
<Text style={[styles.errorText, { color: colorTokens.text }]}>任务不存在</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -222,7 +230,9 @@ export default function TaskDetailScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scrollView} contentContainerStyle={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} showsVerticalScrollIndicator={false}>
|
||||||
{/* 任务标题和创建时间 */}
|
{/* 任务标题和创建时间 */}
|
||||||
<View style={styles.titleSection}>
|
<View style={styles.titleSection}>
|
||||||
<Text style={[styles.taskTitle, { color: colorTokens.text }]}>{task.title}</Text>
|
<Text style={[styles.taskTitle, { color: colorTokens.text }]}>{task.title}</Text>
|
||||||
@@ -279,10 +289,10 @@ export default function TaskDetailScreen() {
|
|||||||
backgroundColor: task.progressPercentage >= 100
|
backgroundColor: task.progressPercentage >= 100
|
||||||
? '#10B981'
|
? '#10B981'
|
||||||
: task.progressPercentage >= 50
|
: task.progressPercentage >= 50
|
||||||
? '#F59E0B'
|
? '#F59E0B'
|
||||||
: task.progressPercentage > 0
|
: task.progressPercentage > 0
|
||||||
? colorTokens.primary
|
? colorTokens.primary
|
||||||
: '#E5E7EB',
|
: '#E5E7EB',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { tasksApi } from '@/services/tasksApi';
|
import { tasksApi } from '@/services/tasksApi';
|
||||||
import { TaskListItem } from '@/types/goals';
|
import { TaskListItem } from '@/types/goals';
|
||||||
import { getTodayIndexInMonth } from '@/utils/date';
|
import { getTodayIndexInMonth } from '@/utils/date';
|
||||||
@@ -12,10 +13,10 @@ import dayjs from 'dayjs';
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, View } from 'react-native';
|
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function GoalsDetailScreen() {
|
export default function GoalsDetailScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -157,12 +158,15 @@ export default function GoalsDetailScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar
|
|
||||||
backgroundColor="transparent"
|
|
||||||
translucent
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="任务列表"
|
||||||
|
onBack={handleBackPress}
|
||||||
|
transparent={true}
|
||||||
|
withSafeTop={false}
|
||||||
|
/>
|
||||||
{/* 背景渐变 */}
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#F0F9FF', '#E0F2FE']}
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
@@ -175,14 +179,13 @@ export default function GoalsDetailScreen() {
|
|||||||
<View style={styles.decorativeCircle1} />
|
<View style={styles.decorativeCircle1} />
|
||||||
<View style={styles.decorativeCircle2} />
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* 标题区域 */}
|
|
||||||
<HeaderBar
|
|
||||||
title="任务列表"
|
|
||||||
onBack={handleBackPress}
|
|
||||||
transparent={true}
|
|
||||||
withSafeTop={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 日期选择器 */}
|
{/* 日期选择器 */}
|
||||||
<View style={styles.dateSelector}>
|
<View style={styles.dateSelector}>
|
||||||
@@ -214,7 +217,7 @@ export default function GoalsDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string>('');
|
|
||||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
|
||||||
const [pickerDate, setPickerDate] = useState<Date>(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 (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<HeaderBar title={editingId ? '编辑训练计划' : '新建训练计划'} onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
|
||||||
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
|
||||||
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<ThemedText style={styles.errorText}>⚠️ {error}</ThemedText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<ThemedText style={styles.cardTitle}>计划名称</ThemedText>
|
|
||||||
<TextInput
|
|
||||||
placeholder="为你的训练计划起个名字(可选)"
|
|
||||||
value={draft.name || ''}
|
|
||||||
onChangeText={(text) => dispatch(setName(text))}
|
|
||||||
style={styles.nameInput}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<ThemedText style={styles.cardTitle}>训练频率</ThemedText>
|
|
||||||
<View style={styles.segment}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => dispatch(setMode('daysOfWeek'))}
|
|
||||||
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}>按星期选择</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => dispatch(setMode('sessionsPerWeek'))}
|
|
||||||
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}>每周次数</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{draft.mode === 'daysOfWeek' ? (
|
|
||||||
<View style={styles.weekRow}>
|
|
||||||
{WEEK_DAYS.map((d, i) => {
|
|
||||||
const active = draft.daysOfWeek.includes(i);
|
|
||||||
return (
|
|
||||||
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
|
|
||||||
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.sliderRow}>
|
|
||||||
<ThemedText style={styles.sliderLabel}>每周训练</ThemedText>
|
|
||||||
<View style={styles.counter}>
|
|
||||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
|
|
||||||
<ThemedText style={styles.counterBtnText}>-</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
|
|
||||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
|
|
||||||
<ThemedText style={styles.counterBtnText}>+</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<ThemedText style={styles.sliderSuffix}>次</ThemedText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ThemedText style={styles.helper}>已选择:{selectedCount} 次/周</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<ThemedText style={styles.cardTitle}>训练目标</ThemedText>
|
|
||||||
<View style={styles.goalGrid}>
|
|
||||||
{GOALS.map((g) => {
|
|
||||||
const active = draft.goal === g.key;
|
|
||||||
return (
|
|
||||||
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
|
|
||||||
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
|
|
||||||
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<ThemedText style={styles.cardTitle}>更多选项</ThemedText>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<ThemedText style={styles.label}>开始日期</ThemedText>
|
|
||||||
<View style={styles.rowRight}>
|
|
||||||
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
|
|
||||||
<ThemedText style={styles.linkText}>选择日期</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
|
|
||||||
<ThemedText style={styles.linkText}>下周一</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
|
|
||||||
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
|
||||||
<TextInput
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="可选"
|
|
||||||
value={weightInput}
|
|
||||||
onChangeText={(t) => {
|
|
||||||
setWeightInput(t);
|
|
||||||
const v = Number(t);
|
|
||||||
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
|
|
||||||
}}
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.rowBetween, { marginTop: 12 }]}>
|
|
||||||
<ThemedText style={styles.label}>偏好时间段</ThemedText>
|
|
||||||
<View style={styles.segmentSmall}>
|
|
||||||
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
|
|
||||||
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
|
|
||||||
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
|
|
||||||
<ThemedText style={styles.primaryBtnText}>
|
|
||||||
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
|
|
||||||
</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View style={{ height: 32 }} />
|
|
||||||
</ScrollView>
|
|
||||||
</ThemedView>
|
|
||||||
<Modal
|
|
||||||
visible={datePickerVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={closeDatePicker}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
|
||||||
<View style={styles.modalSheet}>
|
|
||||||
<DateTimePicker
|
|
||||||
value={pickerDate}
|
|
||||||
mode="date"
|
|
||||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
|
||||||
minimumDate={new Date()}
|
|
||||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
|
||||||
onChange={(event, date) => {
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
if (date) setPickerDate(date);
|
|
||||||
} else {
|
|
||||||
if (event.type === 'set' && date) {
|
|
||||||
onConfirmDate(date);
|
|
||||||
} else {
|
|
||||||
closeDatePicker();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Platform.OS === 'ios' && (
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
|
||||||
<ThemedText style={styles.modalBtnText}>取消</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
|
||||||
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { analyzeFoodFromText } from '@/services/foodRecognition';
|
import { analyzeFoodFromText } from '@/services/foodRecognition';
|
||||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||||
import { triggerHapticFeedback } from '@/utils/haptics';
|
import { triggerHapticFeedback } from '@/utils/haptics';
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
|
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
|
||||||
|
|
||||||
export default function VoiceRecordScreen() {
|
export default function VoiceRecordScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme() ?? 'light';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
|
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
|
||||||
@@ -460,6 +462,10 @@ export default function VoiceRecordScreen() {
|
|||||||
variant="elevated"
|
variant="elevated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* 上半部分:介绍 */}
|
{/* 上半部分:介绍 */}
|
||||||
<View style={styles.topSection}>
|
<View style={styles.topSection}>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||||
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
@@ -12,12 +10,9 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Switch,
|
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
@@ -25,6 +20,7 @@ import {
|
|||||||
import { Swipeable } from 'react-native-gesture-handler';
|
import { Swipeable } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface WaterDetailProps {
|
interface WaterDetailProps {
|
||||||
@@ -32,6 +28,8 @@ interface WaterDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
@@ -194,7 +192,9 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
|||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
|
||||||
const WaterReminderSettings: React.FC = () => {
|
const WaterReminderSettings: React.FC = () => {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
@@ -186,7 +188,9 @@ const WaterReminderSettings: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 开启/关闭提醒 */}
|
{/* 开启/关闭提醒 */}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -15,15 +15,16 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Switch,
|
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
|
||||||
const WaterSettings: React.FC = () => {
|
const WaterSettings: React.FC = () => {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ const WaterSettings: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 设置列表 */}
|
{/* 设置列表 */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -22,6 +23,8 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
export default function WeightRecordsPage() {
|
export default function WeightRecordsPage() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
@@ -184,8 +187,12 @@ export default function WeightRecordsPage() {
|
|||||||
<Ionicons name="add" size={24} color="#192126" />
|
<Ionicons name="add" size={24} color="#192126" />
|
||||||
</TouchableOpacity>}
|
</TouchableOpacity>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}} />
|
||||||
{/* Weight Statistics */}
|
{/* Weight Statistics */}
|
||||||
<View style={styles.statsContainer}>
|
<View style={[styles.statsContainer]}>
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statItem}>
|
<View style={styles.statItem}>
|
||||||
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
|
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
|
||||||
|
|||||||
@@ -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<string, { title: string; color: string; description: string }> = {
|
|
||||||
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 (
|
|
||||||
<View style={StyleSheet.absoluteFillObject}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
|
|
||||||
style={StyleSheet.absoluteFillObject}
|
|
||||||
/>
|
|
||||||
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
|
|
||||||
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string | null>(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 (
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInUp.delay(index * 100)}
|
|
||||||
style={[
|
|
||||||
styles.planCard,
|
|
||||||
{ borderLeftColor: planGoalConfig.color },
|
|
||||||
isSelected && { borderWidth: 2, borderColor: planGoalConfig.color }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.planCardContent}
|
|
||||||
onPress={() => {
|
|
||||||
setSelectedPlanId(isSelected ? null : item.id);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<View style={styles.planHeader}>
|
|
||||||
<View style={styles.planInfo}>
|
|
||||||
<Text style={styles.planName}>{item.name}</Text>
|
|
||||||
<Text style={[styles.planGoal, { color: planGoalConfig.color }]}>
|
|
||||||
{planGoalConfig.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.methodDescription}>
|
|
||||||
{planGoalConfig.description}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.planStatus}>
|
|
||||||
{isSelected ? (
|
|
||||||
<Ionicons name="checkmark-circle" size={24} color={planGoalConfig.color} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.radioButton, { borderColor: planGoalConfig.color }]} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item.exercises && item.exercises.length > 0 && (
|
|
||||||
<View style={styles.planStats}>
|
|
||||||
<Text style={styles.statsText}>
|
|
||||||
{item.exercises.length} 个动作
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.safeArea}>
|
|
||||||
{/* 动态背景 */}
|
|
||||||
<DynamicBackground color={goalConfig.color} />
|
|
||||||
|
|
||||||
<SafeAreaView style={styles.contentWrapper}>
|
|
||||||
<HeaderBar
|
|
||||||
title="新建训练"
|
|
||||||
onBack={() => router.back()}
|
|
||||||
withSafeTop={false}
|
|
||||||
transparent={true}
|
|
||||||
tone="light"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.content}>
|
|
||||||
{/* 会话信息设置 */}
|
|
||||||
<View style={[styles.sessionHeader, { backgroundColor: `${goalConfig.color}20` }]}>
|
|
||||||
<View style={[styles.sessionColorIndicator, { backgroundColor: goalConfig.color }]} />
|
|
||||||
<View style={styles.sessionInfo}>
|
|
||||||
<ThemedText style={styles.sessionTitle}>训练会话设置</ThemedText>
|
|
||||||
<ThemedText style={styles.sessionDescription}>
|
|
||||||
{goalConfig.description}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 会话名称输入 */}
|
|
||||||
<View style={styles.inputSection}>
|
|
||||||
<Text style={styles.inputLabel}>会话名称</Text>
|
|
||||||
<TextInput
|
|
||||||
value={sessionName}
|
|
||||||
onChangeText={setSessionName}
|
|
||||||
placeholder="输入会话名称"
|
|
||||||
placeholderTextColor="#888F92"
|
|
||||||
style={[styles.textInput, { borderColor: `${goalConfig.color}30` }]}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 创建方式选择 */}
|
|
||||||
<View style={styles.methodSection}>
|
|
||||||
<Text style={styles.sectionTitle}>选择创建方式</Text>
|
|
||||||
|
|
||||||
{/* 自定义会话 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.methodCard, { borderColor: `${goalConfig.color}30` }]}
|
|
||||||
onPress={handleCreateCustomSession}
|
|
||||||
disabled={creating || !sessionName.trim()}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<View style={styles.methodIcon}>
|
|
||||||
<Ionicons name="create-outline" size={24} color={goalConfig.color} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.methodInfo}>
|
|
||||||
<Text style={styles.methodTitle}>自定义会话</Text>
|
|
||||||
<Text style={styles.methodDescription}>
|
|
||||||
创建空的训练会话,然后手动添加动作
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 从训练计划导入 */}
|
|
||||||
<View style={styles.planImportSection}>
|
|
||||||
<Text style={styles.methodTitle}>从训练计划导入</Text>
|
|
||||||
<Text style={styles.methodDescription}>
|
|
||||||
选择一个训练计划,将其动作导入到新会话中
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{plansLoading ? (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<Text style={styles.loadingText}>加载训练计划中...</Text>
|
|
||||||
</View>
|
|
||||||
) : plans.length === 0 ? (
|
|
||||||
<View style={styles.emptyPlansContainer}>
|
|
||||||
<Ionicons name="document-outline" size={32} color="#9CA3AF" />
|
|
||||||
<Text style={styles.emptyPlansText}>暂无训练计划</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.createPlanBtn, { backgroundColor: goalConfig.color }]}
|
|
||||||
onPress={() => router.push('/training-plan/create' as any)}
|
|
||||||
>
|
|
||||||
<Text style={styles.createPlanBtnText}>创建训练计划</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FlatList
|
|
||||||
data={plans}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
renderItem={renderPlanItem}
|
|
||||||
contentContainerStyle={styles.plansList}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
scrollEnabled={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedPlan && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.confirmBtn,
|
|
||||||
{ backgroundColor: goalConfig.color },
|
|
||||||
(!sessionName.trim() || creating) && { opacity: 0.5 }
|
|
||||||
]}
|
|
||||||
onPress={handleCreateFromPlan}
|
|
||||||
disabled={!sessionName.trim() || creating}
|
|
||||||
>
|
|
||||||
<Text style={styles.confirmBtnText}>
|
|
||||||
{creating ? '创建中...' : `从 "${selectedPlan.name}" 创建会话`}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||||
|
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||||
import {
|
import {
|
||||||
addHealthPermissionListener,
|
addHealthPermissionListener,
|
||||||
@@ -283,6 +284,8 @@ export default function WorkoutHistoryScreen() {
|
|||||||
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||||
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||||
|
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -532,7 +535,7 @@ export default function WorkoutHistoryScreen() {
|
|||||||
colors={["#F3F5FF", "#FFFFFF"]}
|
colors={["#F3F5FF", "#FFFFFF"]}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
/>
|
/>
|
||||||
<HeaderBar title="锻炼历史" variant="minimal" transparent={true} />
|
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color="#5C55FF" />
|
<ActivityIndicator size="large" color="#5C55FF" />
|
||||||
@@ -540,7 +543,9 @@ export default function WorkoutHistoryScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<SectionList
|
||||||
style={styles.sectionList}
|
style={[styles.sectionList, {
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}]}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
import {
|
import {
|
||||||
getWorkoutNotificationPreferences,
|
getWorkoutNotificationPreferences,
|
||||||
resetWorkoutNotificationPreferences,
|
resetWorkoutNotificationPreferences,
|
||||||
@@ -30,6 +31,7 @@ const WORKOUT_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkoutNotificationSettingsScreen() {
|
export default function WorkoutNotificationSettingsScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [preferences, setPreferences] = useState<WorkoutNotificationPreferences>({
|
const [preferences, setPreferences] = useState<WorkoutNotificationPreferences>({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -152,6 +154,9 @@ export default function WorkoutNotificationSettingsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||||
|
<View style={{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}}></View>
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<Text>加载中...</Text>
|
<Text>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -163,7 +168,11 @@ export default function WorkoutNotificationSettingsScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||||
|
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false} contentContainerStyle={
|
||||||
|
{
|
||||||
|
paddingTop: safeAreaTop
|
||||||
|
}
|
||||||
|
}>
|
||||||
{/* 主开关 */}
|
{/* 主开关 */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.settingItem}>
|
<View style={styles.settingItem}>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
getWorkoutTypeDisplayName,
|
getWorkoutTypeDisplayName,
|
||||||
HealthPermissionStatus,
|
HealthPermissionStatus,
|
||||||
removeHealthPermissionListener,
|
removeHealthPermissionListener,
|
||||||
WorkoutActivityType,
|
WorkoutData
|
||||||
WorkoutData,
|
|
||||||
} from '@/utils/health';
|
} from '@/utils/health';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
@@ -39,21 +38,6 @@ const DEFAULT_SUMMARY: WorkoutSummary = {
|
|||||||
lastWorkout: null,
|
lastWorkout: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconByWorkoutType: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
|
|
||||||
[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<WorkoutSummaryCardProps> = ({ date, style }) => {
|
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -86,34 +70,36 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
|||||||
return;
|
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 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 workoutsBeforeDate = workouts
|
||||||
|
|
||||||
const workoutsInRange = workouts
|
|
||||||
.filter((workout) => {
|
.filter((workout) => {
|
||||||
// 额外防护:确保锻炼记录确实落在当天
|
// 确保锻炼记录在选中日期或之前
|
||||||
const workoutDate = dayjs(workout.startDate);
|
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());
|
.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(
|
const lastWorkout = workoutsBeforeDate.length > 0 ? workoutsBeforeDate[0] : null;
|
||||||
workoutsInRange.reduce((total, workout) => total + (workout.duration || 0), 0) / 60
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
if (isMountedRef.current) {
|
||||||
setSummary({
|
setSummary({
|
||||||
totalCalories,
|
totalCalories,
|
||||||
totalMinutes,
|
totalMinutes,
|
||||||
workouts: workoutsInRange,
|
workouts: recentWorkouts,
|
||||||
lastWorkout,
|
lastWorkout,
|
||||||
});
|
});
|
||||||
setResetToken((token) => token + 1);
|
setResetToken((token) => token + 1);
|
||||||
@@ -153,10 +139,6 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
|||||||
router.push('/workout/history');
|
router.push('/workout/history');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleAddPress = useCallback(() => {
|
|
||||||
router.push('/workout/create-session');
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const cardContent = useMemo(() => {
|
const cardContent = useMemo(() => {
|
||||||
const hasWorkouts = summary.workouts.length > 0;
|
const hasWorkouts = summary.workouts.length > 0;
|
||||||
const lastWorkout = summary.lastWorkout;
|
const lastWorkout = summary.lastWorkout;
|
||||||
@@ -213,7 +195,7 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
|||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
|
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
|
||||||
<Text style={styles.titleText}>健身</Text>
|
<Text style={styles.titleText}>近期锻炼</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { FloatingSelectionCard } from '@/components/ui/FloatingSelectionCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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 = {
|
type FastingStartPickerModalProps = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -119,20 +119,17 @@ export function FastingStartPickerModal({
|
|||||||
lastAppliedTimestamp.current = recommendedDate.getTime();
|
lastAppliedTimestamp.current = recommendedDate.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickerIndicatorStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
backgroundColor: `${colors.primary}12`,
|
|
||||||
borderRadius: 12,
|
|
||||||
}),
|
|
||||||
[colors.primary]
|
|
||||||
);
|
|
||||||
|
|
||||||
const textStyle = {
|
const textStyle = {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '600' as const,
|
fontWeight: '600' as const,
|
||||||
color: '#2E3142',
|
color: '#2E3142',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 自定义渲染函数,用于应用文本样式
|
||||||
|
const renderItem = ({ fontSize, label, fontColor, textAlign }: { fontSize: number; label: string; fontColor: string; textAlign: 'center' | 'auto' | 'left' | 'right' | 'justify' }) => (
|
||||||
|
<Text style={[textStyle, { fontSize, color: fontColor, textAlign }]}>{label}</Text>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingSelectionCard
|
<FloatingSelectionCard
|
||||||
visible={visible}
|
visible={visible}
|
||||||
@@ -148,8 +145,7 @@ export function FastingStartPickerModal({
|
|||||||
items={dayOptions.map((item) => ({ label: item.label, value: item.offset }))}
|
items={dayOptions.map((item) => ({ label: item.label, value: item.offset }))}
|
||||||
onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))}
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))}
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
itemTextStyle={textStyle}
|
renderItem={renderItem}
|
||||||
selectedIndicatorStyle={pickerIndicatorStyle}
|
|
||||||
haptics
|
haptics
|
||||||
/>
|
/>
|
||||||
<WheelPickerExpo
|
<WheelPickerExpo
|
||||||
@@ -160,8 +156,7 @@ export function FastingStartPickerModal({
|
|||||||
items={HOURS.map((hour) => ({ label: hour.toString().padStart(2, '0'), value: hour }))}
|
items={HOURS.map((hour) => ({ label: hour.toString().padStart(2, '0'), value: hour }))}
|
||||||
onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))}
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))}
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
itemTextStyle={textStyle}
|
renderItem={renderItem}
|
||||||
selectedIndicatorStyle={pickerIndicatorStyle}
|
|
||||||
haptics
|
haptics
|
||||||
/>
|
/>
|
||||||
<WheelPickerExpo
|
<WheelPickerExpo
|
||||||
@@ -175,8 +170,7 @@ export function FastingStartPickerModal({
|
|||||||
}))}
|
}))}
|
||||||
onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))}
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))}
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
itemTextStyle={textStyle}
|
renderItem={renderItem}
|
||||||
selectedIndicatorStyle={pickerIndicatorStyle}
|
|
||||||
haptics
|
haptics
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export function FloatingSelectionCard({
|
|||||||
];
|
];
|
||||||
const closeWrapperProps = glassAvailable
|
const closeWrapperProps = glassAvailable
|
||||||
? {
|
? {
|
||||||
glassEffectStyle: 'regular' as const,
|
glassEffectStyle: 'regular' as const,
|
||||||
tintColor: 'rgba(255,255,255,0.45)',
|
tintColor: 'rgba(255,255,255,0.45)',
|
||||||
isInteractive: true,
|
isInteractive: true,
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -136,9 +136,6 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
closeButtonInnerGlass: {
|
closeButtonInnerGlass: {
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: 'rgba(255,255,255,0.45)',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.35)',
|
|
||||||
},
|
},
|
||||||
closeButtonInnerFallback: {
|
closeButtonInnerFallback: {
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
@@ -58,12 +59,14 @@ export function HeaderBar({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultBackColor = 'rgba(0,0,0,0.8)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.header,
|
styles.header,
|
||||||
{
|
{
|
||||||
paddingTop: (withSafeTop ? insets.top : 0) + 8,
|
paddingTop: insets.top,
|
||||||
backgroundColor: getBackgroundColor(),
|
backgroundColor: getBackgroundColor(),
|
||||||
...getBorderStyle(),
|
...getBorderStyle(),
|
||||||
...(variant === 'elevated' && {
|
...(variant === 'elevated' && {
|
||||||
@@ -76,24 +79,51 @@ export function HeaderBar({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
{isLiquidGlassAvailable() ? (
|
||||||
accessibilityRole="button"
|
<TouchableOpacity
|
||||||
onPress={() => {
|
accessibilityRole="button"
|
||||||
if (onBack) {
|
onPress={() => {
|
||||||
onBack();
|
if (onBack) {
|
||||||
return
|
onBack();
|
||||||
}
|
return
|
||||||
router.back()
|
}
|
||||||
}}
|
router.back()
|
||||||
style={styles.backButton}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<GlassView
|
||||||
name="chevron-back"
|
style={styles.backButton}
|
||||||
size={24}
|
glassEffectStyle="clear"
|
||||||
color={backColor || theme.text}
|
tintColor="rgba(255, 255, 255, 0.2)"
|
||||||
/>
|
isInteractive={true}
|
||||||
</TouchableOpacity>
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={backColor || defaultBackColor}
|
||||||
|
/>
|
||||||
|
</GlassView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
if (onBack) {
|
||||||
|
onBack();
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.back()
|
||||||
|
}}
|
||||||
|
style={[styles.backButton, styles.fallbackBackground]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={backColor || defaultBackColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
{typeof title === 'string' ? (
|
{typeof title === 'string' ? (
|
||||||
<Text style={[
|
<Text style={[
|
||||||
@@ -117,6 +147,11 @@ export function HeaderBar({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 2,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -126,10 +161,15 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
width: 32,
|
width: 38,
|
||||||
height: 32,
|
height: 38,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
borderRadius: 38,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fallbackBackground: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||||
},
|
},
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
38
hooks/README.md
Normal file
38
hooks/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 安全区域 Hooks
|
||||||
|
|
||||||
|
这个目录包含了与设备安全区域相关的 React hooks。
|
||||||
|
|
||||||
|
## useSafeAreaTop
|
||||||
|
|
||||||
|
获取顶部安全区域距离的 hook,可以添加额外的间距。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
|
||||||
|
// 使用默认的 20 像素额外间距
|
||||||
|
const topPadding = useSafeAreaTop();
|
||||||
|
|
||||||
|
// 使用自定义的额外间距
|
||||||
|
const customTopPadding = useSafeAreaTop(10);
|
||||||
|
```
|
||||||
|
|
||||||
|
## useSafeAreaWithPadding
|
||||||
|
|
||||||
|
获取所有方向的安全区域距离,并可以为每个方向添加不同的额外间距。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useSafeAreaWithPadding } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
|
||||||
|
// 使用默认值(无额外间距)
|
||||||
|
const safeAreas = useSafeAreaWithPadding();
|
||||||
|
|
||||||
|
// 为不同方向添加不同的额外间距
|
||||||
|
const customSafeAreas = useSafeAreaWithPadding({
|
||||||
|
top: 20,
|
||||||
|
bottom: 10,
|
||||||
|
left: 5,
|
||||||
|
right: 5
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
这些 hooks 基于 `react-native-safe-area-context` 库,确保你的应用在不同设备和 iOS 版本上都能正确处理安全区域。
|
||||||
32
hooks/useSafeAreaWithPadding.ts
Normal file
32
hooks/useSafeAreaWithPadding.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取安全区域距离并添加额外间距的 hook
|
||||||
|
* @param extraPadding 额外的间距对象,默认所有方向都是 0
|
||||||
|
* @returns 包含所有方向安全区域距离加上额外间距的对象
|
||||||
|
*/
|
||||||
|
export const useSafeAreaWithPadding = (extraPadding: {
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left?: number;
|
||||||
|
right?: number;
|
||||||
|
} = {}) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ export const getStatusBarHeight = () => {
|
|||||||
return StatusBar.currentHeight;
|
return StatusBar.currentHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const getDeviceDimensions = () => {
|
export const getDeviceDimensions = () => {
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user