feat: 更新心情记录功能和界面
- 调整启动画面中的图片宽度,提升视觉效果 - 移除引导页面相关组件,简化应用结构 - 新增心情统计页面,支持用户查看和分析心情数据 - 优化心情卡片组件,增强用户交互体验 - 更新登录页面标题,提升品牌一致性 - 新增心情日历和编辑功能,支持用户记录和管理心情
This commit is contained in:
@@ -1,23 +1,25 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { BMICard } from '@/components/BMICard';
|
||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodModal } from '@/components/MoodModal';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
@@ -163,29 +165,45 @@ export default function ExploreScreen() {
|
||||
const [isNutritionLoading, setIsNutritionLoading] = useState(false);
|
||||
|
||||
// 心情相关状态
|
||||
const [moodModalVisible, setMoodModalVisible] = useState(false);
|
||||
const [moodRecords, setMoodRecords] = useState<Array<{ mood: string, date: string, time: string }>>([]);
|
||||
const dispatch = useAppDispatch();
|
||||
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
||||
|
||||
// 从 Redux 获取当前日期的心情记录
|
||||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||||
days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD')
|
||||
));
|
||||
|
||||
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||||
const latestRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||
|
||||
// 心情保存处理函数
|
||||
const handleMoodSave = (mood: string, time: string) => {
|
||||
const today = new Date();
|
||||
const dateString = `${today.getFullYear()}年${today.getMonth() + 1}月${today.getDate()}日`;
|
||||
// 加载心情数据
|
||||
const loadMoodData = async (targetDate?: Date) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const newRecord = {
|
||||
mood,
|
||||
date: dateString,
|
||||
time
|
||||
};
|
||||
try {
|
||||
setIsMoodLoading(true);
|
||||
|
||||
setMoodRecords(prev => [newRecord, ...prev]);
|
||||
setMoodModalVisible(false);
|
||||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}
|
||||
|
||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||
await dispatch(fetchDailyMoodCheckins(dateString));
|
||||
} catch (error) {
|
||||
console.error('加载心情数据失败:', error);
|
||||
} finally {
|
||||
setIsMoodLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const loadHealthData = async (targetDate?: Date) => {
|
||||
try {
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
@@ -290,6 +308,7 @@ export default function ExploreScreen() {
|
||||
loadHealthData(currentDate);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(currentDate);
|
||||
loadMoodData(currentDate);
|
||||
}
|
||||
}
|
||||
}, [selectedIndex])
|
||||
@@ -303,6 +322,7 @@ export default function ExploreScreen() {
|
||||
loadHealthData(target);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(target);
|
||||
loadMoodData(target);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -424,23 +444,11 @@ export default function ExploreScreen() {
|
||||
</FloatingCard>
|
||||
{/* 心情卡片 */}
|
||||
<FloatingCard style={[styles.masonryCard, styles.moodCard]} delay={1500}>
|
||||
<TouchableOpacity onPress={() => setMoodModalVisible(true)} style={styles.moodCardContent}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<View style={styles.moodIconContainer}>
|
||||
<Text style={styles.moodIcon}>😊</Text>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
</View>
|
||||
<Text style={styles.moodSubtitle}>记录你的每日心情</Text>
|
||||
{moodRecords.length > 0 ? (
|
||||
<View style={styles.moodPreview}>
|
||||
<Text style={styles.moodPreviewText}>今日:{moodRecords[0].mood}</Text>
|
||||
<Text style={styles.moodPreviewTime}>{moodRecords[0].time}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.moodEmptyText}>点击记录心情</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<MoodCard
|
||||
moodCheckin={currentMoodCheckin}
|
||||
onPress={() => router.push('/mood/calendar')}
|
||||
isLoading={isMoodLoading}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
@@ -484,13 +492,6 @@ export default function ExploreScreen() {
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* 心情弹窗 */}
|
||||
<MoodModal
|
||||
visible={moodModalVisible}
|
||||
onClose={() => setMoodModalVisible(false)}
|
||||
onSave={handleMoodSave}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -866,46 +867,4 @@ const styles = StyleSheet.create({
|
||||
moodCard: {
|
||||
backgroundColor: '#F0FDF4',
|
||||
},
|
||||
moodCardContent: {
|
||||
width: '100%',
|
||||
},
|
||||
moodIconContainer: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#DCFCE7',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
},
|
||||
moodIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
moodSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginTop: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
moodPreview: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
moodPreviewText: {
|
||||
fontSize: 14,
|
||||
color: '#059669',
|
||||
fontWeight: '600',
|
||||
},
|
||||
moodPreviewTime: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
},
|
||||
moodEmptyText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,7 +79,6 @@ export default function RootLayout() {
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { login } from '@/store/userSlice';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
@@ -238,8 +237,8 @@ export default function LoginScreen() {
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>普拉提助手</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录普拉提星球</ThemedText>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Sealife</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Sealife</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
|
||||
287
app/mood-statistics.tsx
Normal file
287
app/mood-statistics.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
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 dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
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}>
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.title}>心情统计</Text>
|
||||
|
||||
{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',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginTop: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
586
app/mood/calendar.tsx
Normal file
586
app/mood/calendar.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useMoodData } from '@/hooks/useMoodData';
|
||||
import { getMoodOptions } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// 心情日历数据生成函数
|
||||
const generateCalendarData = (targetDate: Date) => {
|
||||
const year = targetDate.getFullYear();
|
||||
const month = targetDate.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfWeek = new Date(year, month, 1).getDay();
|
||||
|
||||
const calendar = [];
|
||||
const weeks = [];
|
||||
|
||||
// 添加空白日期
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
weeks.push(null);
|
||||
}
|
||||
|
||||
// 添加实际日期
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
weeks.push(day);
|
||||
}
|
||||
|
||||
// 按周分组
|
||||
for (let i = 0; i < weeks.length; i += 7) {
|
||||
calendar.push(weeks.slice(i, i + 7));
|
||||
}
|
||||
|
||||
return { calendar, today: new Date().getDate(), month: month + 1, year };
|
||||
};
|
||||
|
||||
export default function MoodCalendarScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const params = useLocalSearchParams();
|
||||
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||
|
||||
const { selectedDate } = params;
|
||||
const initialDate = selectedDate ? dayjs(selectedDate as string).toDate() : new Date();
|
||||
|
||||
const [currentMonth, setCurrentMonth] = useState(initialDate);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [selectedDateMood, setSelectedDateMood] = useState<any>(null);
|
||||
const [moodRecords, setMoodRecords] = useState<Record<string, any[]>>({});
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
// 生成当前月份的日历数据
|
||||
const { calendar, today, month, year } = generateCalendarData(currentMonth);
|
||||
|
||||
// 初始化选中日期
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const date = dayjs(selectedDate as string);
|
||||
setCurrentMonth(date.toDate());
|
||||
setSelectedDay(date.date());
|
||||
const dateString = date.format('YYYY-MM-DD');
|
||||
loadDailyMoodCheckins(dateString);
|
||||
} else {
|
||||
const today = new Date();
|
||||
setCurrentMonth(today);
|
||||
setSelectedDay(today.getDate());
|
||||
const dateString = dayjs().format('YYYY-MM-DD');
|
||||
loadDailyMoodCheckins(dateString);
|
||||
}
|
||||
loadMonthMoodData(currentMonth);
|
||||
}, [selectedDate]);
|
||||
|
||||
// 加载整个月份的心情数据
|
||||
const loadMonthMoodData = async (targetMonth: Date) => {
|
||||
try {
|
||||
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
|
||||
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
|
||||
|
||||
const historyData = await fetchMoodHistoryRecords({ startDate, endDate });
|
||||
|
||||
// 将历史记录按日期分组
|
||||
const monthData: Record<string, any[]> = {};
|
||||
historyData.forEach(checkin => {
|
||||
const date = checkin.checkinDate;
|
||||
if (!monthData[date]) {
|
||||
monthData[date] = [];
|
||||
}
|
||||
monthData[date].push(checkin);
|
||||
});
|
||||
|
||||
setMoodRecords(monthData);
|
||||
} catch (error) {
|
||||
console.error('加载月份心情数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载选中日期的心情记录
|
||||
const loadDailyMoodCheckins = async (dateString: string) => {
|
||||
try {
|
||||
const checkins = await fetchMoodRecords(dateString);
|
||||
if (checkins.length > 0) {
|
||||
setSelectedDateMood(checkins[0]); // 取最新的记录
|
||||
} else {
|
||||
setSelectedDateMood(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载心情记录失败:', error);
|
||||
setSelectedDateMood(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 月份切换函数
|
||||
const goToPreviousMonth = () => {
|
||||
const newMonth = new Date(currentMonth);
|
||||
newMonth.setMonth(newMonth.getMonth() - 1);
|
||||
setCurrentMonth(newMonth);
|
||||
setSelectedDay(null);
|
||||
loadMonthMoodData(newMonth);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
const newMonth = new Date(currentMonth);
|
||||
newMonth.setMonth(newMonth.getMonth() + 1);
|
||||
setCurrentMonth(newMonth);
|
||||
setSelectedDay(null);
|
||||
loadMonthMoodData(newMonth);
|
||||
};
|
||||
|
||||
// 日期选择函数
|
||||
const onSelectDate = (day: number) => {
|
||||
setSelectedDay(day);
|
||||
const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
|
||||
loadDailyMoodCheckins(selectedDateString);
|
||||
};
|
||||
|
||||
// 跳转到心情编辑页面
|
||||
const openMoodEdit = () => {
|
||||
const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
const moodId = selectedDateMood?.id;
|
||||
|
||||
router.push({
|
||||
pathname: '/mood/edit',
|
||||
params: {
|
||||
date: selectedDateString,
|
||||
...(moodId && { moodId })
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderMoodIcon = (day: number | null, isSelected: boolean) => {
|
||||
if (!day) return null;
|
||||
|
||||
// 检查该日期是否有心情记录
|
||||
const dayDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
|
||||
const dayRecords = moodRecords[dayDateString] || [];
|
||||
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
|
||||
|
||||
if (moodRecord) {
|
||||
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
|
||||
return (
|
||||
<View style={[styles.moodIconContainer, { backgroundColor: mood?.color }]}>
|
||||
<View style={styles.moodIcon}>
|
||||
<Text style={styles.moodEmoji}>{mood?.emoji || '😊'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.defaultMoodIcon}>
|
||||
<Text style={styles.defaultMoodEmoji}>😊</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 使用统一的渐变背景色
|
||||
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||
|
||||
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.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Text style={styles.backButton}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>心情日历</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{/* 日历视图 */}
|
||||
<View style={styles.calendar}>
|
||||
{/* 月份导航 */}
|
||||
<View style={styles.monthNavigation}>
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={goToPreviousMonth}
|
||||
>
|
||||
<Text style={styles.navButtonText}>‹</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthTitle}>{year}年{monthNames[month - 1]}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={goToNextMonth}
|
||||
>
|
||||
<Text style={styles.navButtonText}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.weekHeader}>
|
||||
{weekDays.map((day, index) => (
|
||||
<Text key={index} style={styles.weekDay}>{day}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{calendar.map((week, weekIndex) => (
|
||||
<View key={weekIndex} style={styles.weekRow}>
|
||||
{week.map((day, dayIndex) => {
|
||||
const isSelected = day === selectedDay;
|
||||
const isToday = day === today && month === new Date().getMonth() + 1 && year === new Date().getFullYear();
|
||||
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
|
||||
|
||||
return (
|
||||
<View key={dayIndex} style={styles.dayContainer}>
|
||||
{day && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dayButton,
|
||||
isSelected && styles.dayButtonSelected,
|
||||
isToday && styles.dayButtonToday
|
||||
]}
|
||||
onPress={() => !isFutureDate && day && onSelectDate(day)}
|
||||
disabled={isFutureDate}
|
||||
>
|
||||
<View style={styles.dayContent}>
|
||||
<Text style={[
|
||||
styles.dayNumber,
|
||||
isSelected && styles.dayNumberSelected,
|
||||
isToday && styles.dayNumberToday,
|
||||
isFutureDate && styles.dayNumberDisabled
|
||||
]}>
|
||||
{day.toString().padStart(2, '0')}
|
||||
</Text>
|
||||
{renderMoodIcon(day, isSelected)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 选中日期的记录 */}
|
||||
<View style={styles.selectedDateSection}>
|
||||
<View style={styles.selectedDateHeader}>
|
||||
<Text style={styles.selectedDateTitle}>
|
||||
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addMoodButton}
|
||||
onPress={openMoodEdit}
|
||||
>
|
||||
<Text style={styles.addMoodButtonText}>记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{selectedDay ? (
|
||||
selectedDateMood ? (
|
||||
<TouchableOpacity
|
||||
style={styles.moodRecord}
|
||||
onPress={openMoodEdit}
|
||||
>
|
||||
<View style={styles.recordIcon}>
|
||||
<View style={styles.moodIcon}>
|
||||
<Text style={styles.moodEmoji}>
|
||||
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.emoji || '😊'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.recordContent}>
|
||||
<Text style={styles.recordMood}>
|
||||
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
|
||||
</Text>
|
||||
<Text style={styles.recordIntensity}>强度: {selectedDateMood.intensity}</Text>
|
||||
{selectedDateMood.description && (
|
||||
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.spacer} />
|
||||
<Text style={styles.recordTime}>
|
||||
{dayjs(selectedDateMood.createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.emptyRecord}>
|
||||
<Text style={styles.emptyRecordText}>暂无心情记录</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>点击右上角"记录"按钮添加心情</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.emptyRecord}>
|
||||
<Text style={styles.emptyRecordText}>请先选择一个日期</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>点击日历中的日期,然后点击"记录"按钮添加心情</Text>
|
||||
</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,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
backButton: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 24,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
calendar: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
monthNavigation: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
navButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#f8f9fa',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
navButtonText: {
|
||||
fontSize: 20,
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
},
|
||||
weekHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 16,
|
||||
},
|
||||
weekDay: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
width: (width - 96) / 7,
|
||||
},
|
||||
weekRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 16,
|
||||
},
|
||||
dayContainer: {
|
||||
width: (width - 96) / 7,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dayButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
dayButtonSelected: {
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
dayButtonToday: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CAF50',
|
||||
},
|
||||
dayContent: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dayNumber: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
fontWeight: '500',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
zIndex: 1,
|
||||
},
|
||||
dayNumberSelected: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
dayNumberToday: {
|
||||
color: '#4CAF50',
|
||||
fontWeight: '600',
|
||||
},
|
||||
dayNumberDisabled: {
|
||||
color: '#ccc',
|
||||
},
|
||||
moodIconContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
moodIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
moodEmoji: {
|
||||
fontSize: 12,
|
||||
},
|
||||
defaultMoodIcon: {
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
defaultMoodEmoji: {
|
||||
fontSize: 10,
|
||||
opacity: 0.3,
|
||||
},
|
||||
selectedDateSection: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
marginTop: 0,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
selectedDateHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
selectedDateTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
},
|
||||
addMoodButton: {
|
||||
paddingHorizontal: 16,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#4CAF50',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
addMoodButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
moodRecord: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
recordIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#4CAF50',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
recordContent: {
|
||||
flex: 1,
|
||||
},
|
||||
recordMood: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
fontWeight: '500',
|
||||
},
|
||||
recordIntensity: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
recordDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
recordTime: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
},
|
||||
emptyRecord: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
emptyRecordText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyRecordSubtext: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
|
||||
});
|
||||
444
app/mood/edit.tsx
Normal file
444
app/mood/edit.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
||||
import {
|
||||
createMoodRecord,
|
||||
deleteMoodRecord,
|
||||
fetchDailyMoodCheckins,
|
||||
selectMoodRecordsByDate,
|
||||
updateMoodRecord
|
||||
} from '@/store/moodSlice';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
export default function MoodEditScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const params = useLocalSearchParams();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { date, moodId } = params;
|
||||
const selectedDate = date as string || dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [selectedMood, setSelectedMood] = useState<MoodType | ''>('');
|
||||
const [intensity, setIntensity] = useState(5);
|
||||
const [description, setDescription] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [existingMood, setExistingMood] = useState<any>(null);
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
|
||||
const loading = useAppSelector(state => state.mood.loading);
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
// 加载当前日期的心情记录
|
||||
dispatch(fetchDailyMoodCheckins(selectedDate));
|
||||
}, [selectedDate, dispatch]);
|
||||
|
||||
// 当 moodRecords 更新时,查找现有记录
|
||||
useEffect(() => {
|
||||
if (moodId && moodRecords.length > 0) {
|
||||
const mood = moodRecords.find((c: any) => c.id === moodId) || moodRecords[0];
|
||||
setExistingMood(mood);
|
||||
setSelectedMood(mood.moodType);
|
||||
setIntensity(mood.intensity);
|
||||
setDescription(mood.description || '');
|
||||
}
|
||||
}, [moodId, moodRecords]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMood) {
|
||||
Alert.alert('提示', '请选择心情');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (existingMood) {
|
||||
// 更新现有记录
|
||||
await dispatch(updateMoodRecord({
|
||||
id: existingMood.id,
|
||||
moodType: selectedMood,
|
||||
intensity,
|
||||
description: description.trim() || undefined,
|
||||
})).unwrap();
|
||||
} else {
|
||||
// 创建新记录
|
||||
await dispatch(createMoodRecord({
|
||||
moodType: selectedMood,
|
||||
intensity,
|
||||
description: description.trim() || undefined,
|
||||
checkinDate: selectedDate,
|
||||
})).unwrap();
|
||||
}
|
||||
|
||||
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('保存心情失败:', error);
|
||||
Alert.alert('错误', '保存心情失败,请重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingMood) return;
|
||||
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条心情记录吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
|
||||
|
||||
Alert.alert('成功', '心情记录已删除', [
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('删除心情失败:', error);
|
||||
Alert.alert('错误', '删除心情失败,请重试');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderIntensitySlider = () => {
|
||||
return (
|
||||
<View style={styles.intensityContainer}>
|
||||
<Text style={styles.intensityLabel}>心情强度: {intensity}</Text>
|
||||
<View style={styles.intensitySlider}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => (
|
||||
<TouchableOpacity
|
||||
key={level}
|
||||
style={[
|
||||
styles.intensityDot,
|
||||
intensity >= level && styles.intensityDotActive
|
||||
]}
|
||||
onPress={() => setIntensity(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.intensityLabels}>
|
||||
<Text style={styles.intensityLabelText}>轻微</Text>
|
||||
<Text style={styles.intensityLabelText}>强烈</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 使用统一的渐变背景色
|
||||
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||
|
||||
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.header}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Text style={styles.backButton}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{existingMood ? '编辑心情' : '记录心情'}
|
||||
</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{/* 日期显示 */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={styles.dateTitle}>
|
||||
{dayjs(selectedDate).format('YYYY年M月D日')}
|
||||
</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)}
|
||||
>
|
||||
<Text style={styles.moodEmoji}>{mood.emoji}</Text>
|
||||
<Text style={styles.moodLabel}>{mood.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 心情强度选择 */}
|
||||
{selectedMood && (
|
||||
<View style={styles.intensitySection}>
|
||||
<Text style={styles.sectionTitle}>心情强度</Text>
|
||||
{renderIntensitySlider()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 心情描述 */}
|
||||
{selectedMood && (
|
||||
<View style={styles.descriptionSection}>
|
||||
<Text style={styles.sectionTitle}>心情描述(可选)</Text>
|
||||
<TextInput
|
||||
style={styles.descriptionInput}
|
||||
placeholder="描述一下你的心情..."
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
maxLength={200}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text style={styles.characterCount}>{description.length}/200</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={styles.footer}>
|
||||
{existingMood && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteButton, isDeleting && styles.disabledButton]}
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>
|
||||
{isDeleting ? '删除中...' : '删除记录'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, (!selectedMood || isLoading) && styles.disabledButton]}
|
||||
onPress={handleSave}
|
||||
disabled={!selectedMood || isLoading}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
backButton: {
|
||||
fontSize: 24,
|
||||
color: '#666',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 24,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
dateSection: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
},
|
||||
moodSection: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
marginTop: 0,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 16,
|
||||
},
|
||||
moodOptions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
moodOption: {
|
||||
width: '30%',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
selectedMoodOption: {
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CAF50',
|
||||
},
|
||||
moodEmoji: {
|
||||
fontSize: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
moodLabel: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
intensitySection: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
marginTop: 0,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
intensityContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
intensityLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
intensitySlider: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
marginBottom: 8,
|
||||
},
|
||||
intensityDot: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#ddd',
|
||||
},
|
||||
intensityDotActive: {
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
intensityLabels: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
intensityLabelText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
descriptionSection: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
marginTop: 0,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
descriptionInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
minHeight: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
characterCount: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'right',
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: '#4CAF50',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#F44336',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
disabledButton: {
|
||||
backgroundColor: '#ccc',
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="personal-info" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const colorScheme = useColorScheme();
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
|
||||
const handleGetStarted = () => {
|
||||
router.push(ROUTES.ONBOARDING_PERSONAL_INFO);
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem('@onboarding_completed', 'true');
|
||||
router.replace(ROUTES.TAB_COACH);
|
||||
} catch (error) {
|
||||
console.error('保存引导状态失败:', error);
|
||||
router.replace(ROUTES.TAB_COACH);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||
<StatusBar
|
||||
barStyle={'dark-content'}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{/* 跳过按钮 */}
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
||||
<ThemedText style={[styles.skipText, { color: textColor }]}>
|
||||
跳过
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<View style={styles.contentContainer}>
|
||||
{/* Logo 或插图区域 */}
|
||||
<View style={styles.imageContainer}>
|
||||
<View style={[styles.logoPlaceholder, { backgroundColor: primaryColor }]}>
|
||||
<Text style={styles.logoText}>🧘♀️</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 标题和描述 */}
|
||||
<View style={styles.textContainer}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
欢迎来到数字普拉提
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: textColor + '90' }]}>
|
||||
让我们一起开始您的健康之旅{'\n'}
|
||||
个性化的普拉提体验正等着您
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 特色功能点 */}
|
||||
<View style={styles.featuresContainer}>
|
||||
{[
|
||||
{ icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' },
|
||||
{ icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' },
|
||||
{ icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' },
|
||||
].map((feature, index) => (
|
||||
<View key={index} style={styles.featureItem}>
|
||||
<Text style={styles.featureIcon}>{feature.icon}</Text>
|
||||
<View style={styles.featureTextContainer}>
|
||||
<ThemedText style={[styles.featureTitle, { color: textColor }]}>
|
||||
{feature.title}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.featureDesc, { color: textColor + '70' }]}>
|
||||
{feature.desc}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.getStartedButton, { backgroundColor: primaryColor }]}
|
||||
onPress={handleGetStarted}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.getStartedButtonText}>开始体验</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.laterButton} onPress={handleSkip}>
|
||||
<ThemedText style={[styles.laterButtonText, { color: textColor + '70' }]}>
|
||||
稍后再说
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: StatusBar.currentHeight || 44,
|
||||
},
|
||||
skipButton: {
|
||||
position: 'absolute',
|
||||
top: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 60,
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
padding: 8,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
imageContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoPlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 48,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
featuresContainer: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
featureIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: 16,
|
||||
width: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
featureTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
featureDesc: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
buttonContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
getStartedButton: {
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
getStartedButtonText: {
|
||||
color: '#192126',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
laterButton: {
|
||||
height: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
laterButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -1,426 +0,0 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface PersonalInfo {
|
||||
gender: 'male' | 'female' | '';
|
||||
age: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
}
|
||||
|
||||
export default function PersonalInfoScreen() {
|
||||
const colorScheme = useColorScheme();
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const iconColor = useThemeColor({}, 'icon');
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
|
||||
gender: '',
|
||||
age: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
});
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '请选择您的性别',
|
||||
subtitle: '这将帮助我们为您制定更合适的训练计划',
|
||||
type: 'gender' as const,
|
||||
},
|
||||
{
|
||||
title: '请输入您的年龄',
|
||||
subtitle: '年龄信息有助于调整训练强度',
|
||||
type: 'age' as const,
|
||||
},
|
||||
{
|
||||
title: '请输入您的身高',
|
||||
subtitle: '身高信息用于计算身体比例',
|
||||
type: 'height' as const,
|
||||
},
|
||||
{
|
||||
title: '请输入您的体重',
|
||||
subtitle: '体重信息用于个性化训练方案',
|
||||
type: 'weight' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleGenderSelect = (gender: 'male' | 'female') => {
|
||||
setPersonalInfo(prev => ({ ...prev, gender }));
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof PersonalInfo, value: string) => {
|
||||
setPersonalInfo(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const currentStepType = steps[currentStep].type;
|
||||
|
||||
// 验证当前步骤是否已填写
|
||||
if (currentStepType === 'gender' && !personalInfo.gender) {
|
||||
Alert.alert('提示', '请选择您的性别');
|
||||
return;
|
||||
}
|
||||
if (currentStepType === 'age' && !personalInfo.age) {
|
||||
Alert.alert('提示', '请输入您的年龄');
|
||||
return;
|
||||
}
|
||||
if (currentStepType === 'height' && !personalInfo.height) {
|
||||
Alert.alert('提示', '请输入您的身高');
|
||||
return;
|
||||
}
|
||||
if (currentStepType === 'weight' && !personalInfo.weight) {
|
||||
Alert.alert('提示', '请输入您的体重');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem('@onboarding_completed', 'true');
|
||||
router.replace('/(tabs)');
|
||||
} catch (error) {
|
||||
console.error('保存引导状态失败:', error);
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
// 保存用户信息和引导完成状态
|
||||
await AsyncStorage.multiSet([
|
||||
['@onboarding_completed', 'true'],
|
||||
['@user_personal_info', JSON.stringify(personalInfo)],
|
||||
]);
|
||||
console.log('用户信息:', personalInfo);
|
||||
router.replace('/(tabs)');
|
||||
} catch (error) {
|
||||
console.error('保存用户信息失败:', error);
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
|
||||
const renderGenderSelection = () => (
|
||||
<View style={styles.optionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.genderOption,
|
||||
{ borderColor: primaryColor },
|
||||
personalInfo.gender === 'female' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
||||
]}
|
||||
onPress={() => handleGenderSelect('female')}
|
||||
>
|
||||
<Text style={styles.genderIcon}>👩</Text>
|
||||
<ThemedText style={[styles.genderText, { color: textColor }]}>女性</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.genderOption,
|
||||
{ borderColor: primaryColor },
|
||||
personalInfo.gender === 'male' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
||||
]}
|
||||
onPress={() => handleGenderSelect('male')}
|
||||
>
|
||||
<Text style={styles.genderIcon}>👨</Text>
|
||||
<ThemedText style={[styles.genderText, { color: textColor }]}>男性</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderNumberInput = (
|
||||
field: 'age' | 'height' | 'weight',
|
||||
placeholder: string,
|
||||
unit: string
|
||||
) => (
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[styles.inputWrapper, { borderColor: iconColor + '30' }]}>
|
||||
<TextInput
|
||||
style={[styles.numberInput, { color: textColor }]}
|
||||
value={personalInfo[field]}
|
||||
onChangeText={(value) => handleInputChange(field, value)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={iconColor}
|
||||
keyboardType="numeric"
|
||||
maxLength={field === 'age' ? 3 : 4}
|
||||
/>
|
||||
<ThemedText style={[styles.unitText, { color: iconColor }]}>{unit}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderStepContent = () => {
|
||||
const step = steps[currentStep];
|
||||
switch (step.type) {
|
||||
case 'gender':
|
||||
return renderGenderSelection();
|
||||
case 'age':
|
||||
return renderNumberInput('age', '请输入年龄', '岁');
|
||||
case 'height':
|
||||
return renderNumberInput('height', '请输入身高', 'cm');
|
||||
case 'weight':
|
||||
return renderNumberInput('weight', '请输入体重', 'kg');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isStepCompleted = () => {
|
||||
const currentStepType = steps[currentStep].type;
|
||||
switch (currentStepType) {
|
||||
case 'gender':
|
||||
return !!personalInfo.gender;
|
||||
case 'age':
|
||||
return !!personalInfo.age;
|
||||
case 'height':
|
||||
return !!personalInfo.height;
|
||||
case 'weight':
|
||||
return !!personalInfo.weight;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||
<StatusBar
|
||||
barStyle={'dark-content'}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{/* 顶部导航 */}
|
||||
<View style={styles.header}>
|
||||
{currentStep > 0 && (
|
||||
<TouchableOpacity style={styles.backButton} onPress={handlePrevious}>
|
||||
<ThemedText style={[styles.backText, { color: textColor }]}>‹ 返回</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
||||
<ThemedText style={[styles.skipText, { color: iconColor }]}>跳过</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBackground, { backgroundColor: iconColor + '20' }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
backgroundColor: primaryColor,
|
||||
width: `${((currentStep + 1) / steps.length) * 100}%`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={[styles.progressText, { color: iconColor }]}>
|
||||
{currentStep + 1} / {steps.length}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.titleContainer}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
{steps[currentStep].title}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: textColor + '80' }]}>
|
||||
{steps[currentStep].subtitle}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{renderStepContent()}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.nextButton,
|
||||
{ backgroundColor: isStepCompleted() ? primaryColor : iconColor + '30' }
|
||||
]}
|
||||
onPress={handleNext}
|
||||
disabled={!isStepCompleted()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[
|
||||
styles.nextButtonText,
|
||||
{ color: isStepCompleted() ? '#192126' : iconColor }
|
||||
]}>
|
||||
{currentStep === steps.length - 1 ? '完成' : '下一步'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: StatusBar.currentHeight || 44,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
skipButton: {
|
||||
padding: 8,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
progressContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 32,
|
||||
},
|
||||
progressBackground: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
textAlign: 'right',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
titleContainer: {
|
||||
marginBottom: 48,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
genderOption: {
|
||||
width: width * 0.35,
|
||||
height: 120,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
genderIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 8,
|
||||
},
|
||||
genderText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
width: width * 0.6,
|
||||
height: 56,
|
||||
},
|
||||
numberInput: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
unitText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
nextButton: {
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
nextButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user