feat: 更新应用版本和集成腾讯云 COS 上传功能

- 将应用版本更新至 1.0.2,修改相关配置文件
- 集成腾讯云 COS 上传功能,新增相关服务和钩子
- 更新 AI 体态评估页面,支持照片上传和评估结果展示
- 添加雷达图组件以展示评估结果
- 更新样式以适应新功能的展示和交互
- 修改登录页面背景效果,提升用户体验
This commit is contained in:
richarjiang
2025-08-13 15:21:54 +08:00
parent 5814044cee
commit 321947db98
20 changed files with 1664 additions and 342 deletions

View File

@@ -14,6 +14,7 @@ import {
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';
@@ -31,17 +32,17 @@ type Sample = { uri: string; correct: boolean };
const SAMPLES: Record<PoseView, Sample[]> = {
front: [
{ uri: 'https://images.unsplash.com/photo-1594737625785-c6683fc87c73?w=400&q=80&auto=format', correct: true },
{ 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://images.unsplash.com/photo-1554463529-e27854014799?w=400&q=80&auto=format', correct: true },
{ 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://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=400&q=80&auto=format', correct: true },
{ 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 },
],
@@ -50,7 +51,7 @@ const SAMPLES: Record<PoseView, Sample[]> = {
export default function AIPostureAssessmentScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = Colors.dark;
const theme = Colors.light;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
@@ -167,13 +168,13 @@ export default function AIPostureAssessmentScreen() {
function handleStart() {
if (!canStart) return;
// TODO: 调用后端或进入分析页面
Alert.alert('开始测评', '已收集三视角照片准备开始AI体态分析');
// 进入评估中间页面
router.push('/ai-posture-processing');
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="dark" transparent />
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
@@ -207,10 +208,8 @@ export default function AIPostureAssessmentScreen() {
{/* Intro */}
<View style={styles.introBox}>
<Text style={styles.title}>姿</Text>
<Text style={styles.description}>
线
</Text>
<Text style={[styles.title, { color: '#192126' }]}>姿</Text>
<Text style={[styles.description, { color: '#5E6468' }]}>线</Text>
</View>
{/* Upload sections */}
@@ -272,6 +271,10 @@ function UploadTile({
onPickLibrary: () => void;
samples: Sample[];
}) {
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}>
@@ -294,7 +297,7 @@ function UploadTile({
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color="#192126" />
<Ionicons name="camera" size={16} color="#BBF246" />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
@@ -302,19 +305,27 @@ function UploadTile({
)}
</TouchableOpacity>
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
<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}>
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
<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>
);
}
@@ -328,15 +339,15 @@ const styles = StyleSheet.create({
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.04)'
backgroundColor: 'rgba(25,33,38,0.06)'
},
permTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: 'rgba(255,255,255,0.75)',
color: '#5E6468',
marginTop: 6,
fontSize: 13,
},
@@ -367,10 +378,10 @@ const styles = StyleSheet.create({
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
borderColor: 'rgba(25,33,38,0.14)',
},
permSecondaryText: {
color: 'rgba(255,255,255,0.85)',
color: '#384046',
fontSize: 14,
fontWeight: '700',
},
@@ -420,12 +431,12 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
sectionTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: 'rgba(255,255,255,0.55)',
color: '#888F92',
fontSize: 13,
},
uploader: {
@@ -433,8 +444,8 @@ const styles = StyleSheet.create({
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(255,255,255,0.18)',
backgroundColor: '#1E262C',
borderColor: 'rgba(25,33,38,0.14)',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
preview: {
@@ -453,25 +464,27 @@ const styles = StyleSheet.create({
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#BBF246',
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: '#BBF246',
},
placeholderTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: 'rgba(255,255,255,0.65)',
color: '#888F92',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
backgroundColor: 'rgba(255,255,255,0.72)',
},
sampleTitle: {
color: 'rgba(255,255,255,0.8)',
color: '#192126',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
@@ -487,7 +500,7 @@ const styles = StyleSheet.create({
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#111',
backgroundColor: '#F2F4F5',
},
sampleTag: {
alignSelf: 'flex-start',

View File

@@ -0,0 +1,279 @@
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: '#BBF246',
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: '#BBF246',
shadowColor: '#BBF246',
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',
},
});

318
app/ai-posture-result.tsx Normal file
View File

@@ -0,0 +1,318 @@
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('/ai-coach-chat')}>
<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',
},
});

View File

@@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Animated, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
@@ -17,7 +18,52 @@ export default function LoginScreen() {
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
const translateAnim = useRef(new Animated.Value(0)).current;
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnimA = useRef(new Animated.Value(0)).current;
const pulseAnimB = useRef(new Animated.Value(0)).current;
useEffect(() => {
const loopTranslate = Animated.loop(
Animated.sequence([
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
])
);
const loopRotate = Animated.loop(
Animated.sequence([
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
])
);
const loopPulseA = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
])
);
const loopPulseB = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
])
);
loopTranslate.start();
loopRotate.start();
loopPulseA.start();
loopPulseB.start();
return () => {
loopTranslate.stop();
loopRotate.stop();
loopPulseA.stop();
loopPulseB.stop();
};
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
@@ -29,7 +75,21 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert('请先阅读并同意', '勾选“我已阅读并同意用户协议与隐私政策”后才可继续登录');
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并继续',
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
},
},
],
{ cancelable: true }
);
return;
}
action();
@@ -83,11 +143,79 @@ export default function LoginScreen() {
}
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
return (
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: color.background }]}>
<ThemedView style={styles.container}>
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
{/* 动态背景层(置于内容之下) */}
<View pointerEvents="none" style={styles.bgWrap}>
{/* 基础全屏渐变:保证覆盖全屏 */}
<AnimatedLinear
colors={
scheme === 'light'
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
: [color.background, '#0F1112', color.surface]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.bgGradientFull]}
/>
{/* 次级大面积渐变:对角线方向形成层次 */}
<AnimatedLinear
colors={
scheme === 'light'
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={[
styles.bgGradientCover,
{
transform: [
{
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
},
],
opacity: scheme === 'light' ? 0.9 : 0.65,
},
]}
/>
{/* 动感色块 A主色呼吸置于左下 */}
<Animated.View
style={[
styles.accentBlobLarge,
{
backgroundColor: color.ornamentPrimary,
transform: [
{ translateX: -80 },
{ translateY: 320 },
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
],
opacity: scheme === 'light' ? 0.55 : 0.4,
},
]}
/>
{/* 动感色块 B辅色漂移置于右上 */}
<Animated.View
style={[
styles.accentBlobMedium,
{
backgroundColor: color.ornamentAccent,
transform: [
{ translateX: 240 },
{ translateY: -40 },
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
],
opacity: scheme === 'light' ? 0.5 : 0.38,
},
]}
/>
</View>
{/* 自定义头部,与其它页面风格一致 */}
<View style={styles.header}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
@@ -108,11 +236,11 @@ export default function LoginScreen() {
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={!hasAgreed || loading}
disabled={loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
disabledStyle,
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
>
@@ -125,16 +253,16 @@ export default function LoginScreen() {
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onGuestLogin)}
disabled={!hasAgreed || loading}
disabled={loading}
style={({ pressed }) => [
styles.guestButton,
{ borderColor: color.border, backgroundColor: color.surface },
disabledStyle,
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="person-circle-outline" size={22} color={Colors.light.neutral200} style={{ marginRight: 8 }} />
<Text style={[styles.guestText, { color: Colors.light.neutral200 }]}></Text>
<Ionicons name="person-circle-outline" size={22} color={color.neutral200} style={{ marginRight: 8 }} />
<Text style={[styles.guestText, { color: color.neutral200 }]}></Text>
</Pressable>
{/* 协议勾选 */}
@@ -192,6 +320,7 @@ const styles = StyleSheet.create({
fontSize: 32,
fontWeight: '500',
letterSpacing: 0.5,
lineHeight: 38,
},
subtitle: {
marginTop: 8,
@@ -248,6 +377,45 @@ const styles = StyleSheet.create({
link: { fontSize: 12, fontWeight: '600' },
footerHint: { marginTop: 24 },
hintText: { fontSize: 12 },
// 背景样式
bgWrap: {
...StyleSheet.absoluteFillObject,
zIndex: 0,
},
bgGradientFull: {
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
bgGradientCover: {
position: 'absolute',
left: '-10%',
top: '-15%',
width: '130%',
height: '70%',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
accentBlob: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
accentBlobLarge: {
position: 'absolute',
width: 260,
height: 260,
borderRadius: 130,
},
accentBlobMedium: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
});

View File

@@ -1,7 +1,7 @@
import DateTimePicker from '@react-native-community/datetimepicker';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
@@ -39,6 +39,7 @@ export default function TrainingPlanScreen() {
const { draft, current } = useAppSelector((s) => s.trainingPlan);
const [weightInput, setWeightInput] = useState<string>('');
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date());
useEffect(() => {
dispatch(loadTrainingPlan());
@@ -60,12 +61,31 @@ export default function TrainingPlanScreen() {
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 () => {
await dispatch(saveTrainingPlan()).unwrap().catch(() => { });
router.back();
};
const openDatePicker = () => setDatePickerVisible(true);
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) => {
// 只允许今天之后(含今天)的日期
@@ -161,7 +181,7 @@ export default function TrainingPlanScreen() {
</Pressable>
</View>
</View>
<ThemedText style={styles.dateHint}>{new Date(draft.startDate).toLocaleDateString()}</ThemedText>
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
<View style={styles.rowBetween}>
<ThemedText style={styles.label}> (kg)</ThemedText>
@@ -197,13 +217,44 @@ export default function TrainingPlanScreen() {
<View style={{ height: 32 }} />
</ScrollView>
</ThemedView>
<DateTimePickerModal
isVisible={datePickerVisible}
mode="date"
minimumDate={new Date()}
onConfirm={onConfirmDate}
onCancel={closeDatePicker}
/>
<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>
);
}
@@ -239,12 +290,14 @@ const styles = StyleSheet.create({
fontSize: 28,
fontWeight: '800',
color: '#1A1A1A',
lineHeight: 36,
},
subtitle: {
fontSize: 14,
color: '#5E6468',
marginTop: 6,
marginBottom: 16,
lineHeight: 20,
},
card: {
backgroundColor: '#FFFFFF',
@@ -463,6 +516,42 @@ const styles = StyleSheet.create({
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,
},
});