diff --git a/app/(tabs)/goals.tsx b/app/(tabs)/goals.tsx index dfe2537..c21a546 100644 --- a/app/(tabs)/goals.tsx +++ b/app/(tabs)/goals.tsx @@ -197,9 +197,9 @@ export default function GoalsScreen() { - // 导航到目标管理页面 - const handleNavigateToGoals = () => { - router.push('/goals-detail'); + // 导航到任务列表页面 + const handleNavigateToTasks = () => { + router.push('/task-list'); }; // 计算各状态的任务数量 @@ -210,18 +210,38 @@ export default function GoalsScreen() { skipped: tasks.filter(task => task.status === 'skipped').length, }; - // 根据筛选条件过滤任务 + // 根据筛选条件过滤任务,并将已完成的任务放到最后 const filteredTasks = React.useMemo(() => { + let filtered: TaskListItem[] = []; + switch (selectedFilter) { case 'pending': - return tasks.filter(task => task.status === 'pending'); + filtered = tasks.filter(task => task.status === 'pending'); + break; case 'completed': - return tasks.filter(task => task.status === 'completed'); + filtered = tasks.filter(task => task.status === 'completed'); + break; case 'skipped': - return tasks.filter(task => task.status === 'skipped'); + filtered = tasks.filter(task => task.status === 'skipped'); + break; default: - return tasks; + filtered = tasks; + break; } + + // 对所有筛选结果进行排序:已完成的任务放到最后 + return [...filtered].sort((a, b) => { + // 如果a已完成而b未完成,a排在后面 + if (a.status === 'completed' && b.status !== 'completed') { + return 1; + } + // 如果b已完成而a未完成,b排在后面 + if (b.status === 'completed' && a.status !== 'completed') { + return -1; + } + // 如果都已完成或都未完成,保持原有顺序 + return 0; + }); }, [tasks, selectedFilter]); // 处理筛选变化 @@ -346,12 +366,12 @@ export default function GoalsScreen() { - 回顾 + 历史 - + { + console.log('开始测试血氧饱和度数据...'); + const currentDate = getCurrentSelectedDate(); + await testOxygenSaturationData(currentDate); + }; + // 使用统一的渐变背景色 const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; @@ -450,6 +457,13 @@ export default function ExploreScreen() { style={styles.basalMetabolismCardOverride} oxygenSaturation={oxygenSaturation} /> + {/* 测试按钮 - 开发时使用 */} + + 测试血氧数据 + {/* 心率卡片 */} @@ -794,4 +808,11 @@ const styles = StyleSheet.create({ moodCard: { backgroundColor: '#F0FDF4', }, + testButton: { + fontSize: 12, + color: '#3B82F6', + textAlign: 'center', + marginTop: 8, + padding: 4, + }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 1212616..2755dcf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,6 +3,8 @@ import { useFonts } from 'expo-font'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; +import 'react-native-gesture-handler'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -74,30 +76,33 @@ export default function RootLayout() { } return ( - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + ); } diff --git a/app/goals-detail.tsx b/app/goals-detail.tsx deleted file mode 100644 index 4bfe51a..0000000 --- a/app/goals-detail.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import CreateGoalModal from '@/components/CreateGoalModal'; -import { DateSelector } from '@/components/DateSelector'; -import { GoalItem } from '@/components/GoalCard'; -import { GoalCarousel } from '@/components/GoalCarousel'; -import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector'; -import { TimelineSchedule } from '@/components/TimelineSchedule'; -import { Colors } from '@/constants/Colors'; -import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { clearErrors, createGoal, fetchGoals } from '@/store/goalsSlice'; -import { CreateGoalRequest, GoalListItem } from '@/types/goals'; -import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; -import { useFocusEffect } from '@react-navigation/native'; -import dayjs from 'dayjs'; -import isBetween from 'dayjs/plugin/isBetween'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useRouter } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -dayjs.extend(isBetween); - -// 将目标转换为GoalItem的辅助函数 -const goalToGoalItem = (goal: GoalListItem): GoalItem => { - return { - id: goal.id, - title: goal.title, - description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`, - time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '', - category: getCategoryFromGoal(goal.category), - priority: getPriorityFromGoal(goal.priority), - }; -}; - -// 获取重复类型标签 -const getRepeatTypeLabel = (repeatType: string): string => { - switch (repeatType) { - case 'daily': return '每日'; - case 'weekly': return '每周'; - case 'monthly': return '每月'; - default: return '自定义'; - } -}; - -// 从目标分类获取GoalItem分类 -const getCategoryFromGoal = (category?: string): GoalItem['category'] => { - if (!category) return 'personal'; - if (category.includes('运动') || category.includes('健身')) return 'workout'; - if (category.includes('工作')) return 'work'; - if (category.includes('健康')) return 'health'; - if (category.includes('财务')) return 'finance'; - return 'personal'; -}; - -// 从目标优先级获取GoalItem优先级 -const getPriorityFromGoal = (priority: number): GoalItem['priority'] => { - if (priority >= 8) return 'high'; - if (priority >= 5) return 'medium'; - return 'low'; -}; - -// 将目标转换为时间轴事件的辅助函数 -const goalToTimelineEvent = (goal: GoalListItem) => { - return { - id: goal.id, - title: goal.title, - startTime: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString(), - endTime: goal.endTime ? dayjs().startOf('day').add(goal.endTime, 'minute').toISOString() : undefined, - category: getCategoryFromGoal(goal.category), - isCompleted: goal.status === 'completed', - }; -}; - -export default function GoalsDetailScreen() { - const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; - const colorTokens = Colors[theme]; - const dispatch = useAppDispatch(); - const router = useRouter(); - - // Redux状态 - const { - goals, - goalsLoading, - goalsError, - createLoading, - createError - } = useAppSelector((state) => state.goals); - - const [selectedTab, setSelectedTab] = useState('day'); - const [selectedDate, setSelectedDate] = useState(new Date()); - const [showCreateModal, setShowCreateModal] = useState(false); - - // 页面聚焦时重新加载数据 - useFocusEffect( - useCallback(() => { - console.log('useFocusEffect - loading goals'); - dispatch(fetchGoals({ - status: 'active', - page: 1, - pageSize: 200, - })); - }, [dispatch]) - ); - - // 处理错误提示 - useEffect(() => { - console.log('goalsError', goalsError); - console.log('createError', createError); - if (goalsError) { - Alert.alert('错误', goalsError); - dispatch(clearErrors()); - } - if (createError) { - Alert.alert('创建失败', createError); - dispatch(clearErrors()); - } - }, [goalsError, createError, dispatch]); - - // 创建目标处理函数 - const handleCreateGoal = async (goalData: CreateGoalRequest) => { - try { - await dispatch(createGoal(goalData)).unwrap(); - setShowCreateModal(false); - Alert.alert('成功', '目标创建成功!'); - } catch (error) { - // 错误已在useEffect中处理 - } - }; - - // tab切换处理函数 - const handleTabChange = (tab: TimeTabType) => { - setSelectedTab(tab); - - // 当切换到周或月模式时,如果当前选择的日期不是今天,则重置为今天 - const today = new Date(); - const currentDate = selectedDate; - - if (tab === 'week' || tab === 'month') { - // 如果当前选择的日期不是今天,重置为今天 - if (!dayjs(currentDate).isSame(dayjs(today), 'day')) { - setSelectedDate(today); - setSelectedIndex(getTodayIndexInMonth()); - } - } else if (tab === 'day') { - // 天模式下也重置为今天 - setSelectedDate(today); - setSelectedIndex(getTodayIndexInMonth()); - } - }; - - // 日期选择器相关状态 (参考 statistics.tsx) - const days = getMonthDaysZh(); - const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); - const monthTitle = getMonthTitleZh(); - - // 日期条自动滚动到选中项 - const daysScrollRef = useRef(null); - const [scrollWidth, setScrollWidth] = useState(0); - const DAY_PILL_WIDTH = 48; - const DAY_PILL_SPACING = 8; - - const scrollToIndex = (index: number, animated = true) => { - if (!daysScrollRef.current || scrollWidth === 0) return; - - const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING; - const baseOffset = index * itemWidth; - const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2)); - - // 确保不会滚动超出边界 - const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth); - const finalOffset = Math.min(centerOffset, maxScrollOffset); - - daysScrollRef.current.scrollTo({ x: finalOffset, animated }); - }; - - useEffect(() => { - if (scrollWidth > 0) { - scrollToIndex(selectedIndex, false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scrollWidth]); - - // 当选中索引变化时,滚动到对应位置 - useEffect(() => { - if (scrollWidth > 0) { - scrollToIndex(selectedIndex, true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedIndex]); - - // 日期选择处理 - const onSelectDate = (index: number) => { - setSelectedIndex(index); - const targetDate = days[index]?.date?.toDate(); - if (targetDate) { - setSelectedDate(targetDate); - - // 在周模式下,如果用户选择了新日期,更新周的显示范围 - if (selectedTab === 'week') { - // 自动滚动到新选择的日期 - setTimeout(() => { - scrollToIndex(index, true); - }, 100); - } - } - }; - - // 将目标转换为GoalItem数据 - const todayGoals = useMemo(() => { - const today = dayjs(); - const activeGoals = goals.filter(goal => - goal.status === 'active' && - (goal.repeatType === 'daily' || - (goal.repeatType === 'weekly' && today.day() !== 0) || - (goal.repeatType === 'monthly' && today.date() <= 28)) - ); - return activeGoals.map(goalToGoalItem); - }, [goals]); - - // 将目标转换为时间轴事件数据 - const filteredTimelineEvents = useMemo(() => { - const selected = dayjs(selectedDate); - let filteredGoals: GoalListItem[] = []; - - switch (selectedTab) { - case 'day': - filteredGoals = goals.filter(goal => { - if (goal.status !== 'active') return false; - if (goal.repeatType === 'daily') return true; - if (goal.repeatType === 'weekly') return selected.day() !== 0; - if (goal.repeatType === 'monthly') return selected.date() <= 28; - return false; - }); - break; - case 'week': - filteredGoals = goals.filter(goal => - goal.status === 'active' && - (goal.repeatType === 'daily' || goal.repeatType === 'weekly') - ); - break; - case 'month': - filteredGoals = goals.filter(goal => goal.status === 'active'); - break; - default: - filteredGoals = goals.filter(goal => goal.status === 'active'); - } - - return filteredGoals.map(goalToTimelineEvent); - }, [selectedTab, selectedDate, goals]); - - console.log('filteredTimelineEvents', filteredTimelineEvents); - - const handleGoalPress = (item: GoalItem) => { - console.log('Goal pressed:', item.title); - // 这里可以导航到目标详情页面 - }; - - const handleEventPress = (event: any) => { - console.log('Event pressed:', event.title); - // 这里可以处理时间轴事件点击 - }; - - const handleBackPress = () => { - router.back(); - }; - - return ( - - - - {/* 背景渐变 */} - - - {/* 装饰性圆圈 */} - - - - - {/* 标题区域 */} - - - - - - 目标管理 - - setShowCreateModal(true)} - > - + - - - - {/* 今日目标卡片 */} - - - {/* 时间筛选选项卡 */} - - - {/* 日期选择器 - 在周和月模式下显示 */} - {(selectedTab === 'week' || selectedTab === 'month') && ( - - onSelectDate(index)} - showMonthTitle={true} - disableFutureDates={true} - /> - - )} - - {/* 时间轴安排 */} - - - - - {/* 创建目标弹窗 */} - setShowCreateModal(false)} - onSubmit={handleCreateGoal} - loading={createLoading} - /> - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - gradientBackground: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, - opacity: 0.6, - }, - decorativeCircle1: { - position: 'absolute', - top: -20, - right: -20, - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: '#0EA5E9', - opacity: 0.1, - }, - decorativeCircle2: { - position: 'absolute', - bottom: -15, - left: -15, - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#0EA5E9', - opacity: 0.05, - }, - content: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 16, - }, - backButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: 'rgba(255, 255, 255, 0.8)', - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - backButtonText: { - color: '#0EA5E9', - fontSize: 20, - fontWeight: '600', - }, - pageTitle: { - fontSize: 24, - fontWeight: '700', - flex: 1, - textAlign: 'center', - }, - addButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#0EA5E9', - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - addButtonText: { - color: '#FFFFFF', - fontSize: 24, - fontWeight: '600', - lineHeight: 24, - }, - timelineSection: { - flex: 1, - backgroundColor: 'rgba(255, 255, 255, 0.95)', - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: 'hidden', - }, - // 日期选择器样式 - dateSelector: { - paddingHorizontal: 20, - paddingVertical: 16, - }, -}); diff --git a/app/goals-list.tsx b/app/goals-list.tsx new file mode 100644 index 0000000..0d7ea5b --- /dev/null +++ b/app/goals-list.tsx @@ -0,0 +1,400 @@ +import { GoalCard } from '@/components/GoalCard'; +import { Colors } from '@/constants/Colors'; +import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { deleteGoal, fetchGoals, loadMoreGoals } from '@/store/goalsSlice'; +import { GoalListItem } from '@/types/goals'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useFocusEffect } from '@react-navigation/native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +export default function GoalsListScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const dispatch = useAppDispatch(); + const router = useRouter(); + + // Redux状态 + const { + goals, + goalsLoading, + goalsError, + goalsPagination, + } = useAppSelector((state) => state.goals); + + const [refreshing, setRefreshing] = useState(false); + + // 页面聚焦时重新加载数据 + useFocusEffect( + useCallback(() => { + console.log('useFocusEffect - loading goals'); + loadGoals(); + }, [dispatch]) + ); + + // 加载目标列表 + const loadGoals = async () => { + try { + await dispatch(fetchGoals({ + page: 1, + pageSize: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + })).unwrap(); + } catch (error) { + console.error('Failed to load goals:', error); + // 在开发模式下,如果API调用失败,使用模拟数据 + if (__DEV__) { + console.log('Using mock data for development'); + // 添加模拟数据用于测试左滑删除功能 + const mockGoals: GoalListItem[] = [ + { + id: 'mock-1', + userId: 'test-user-1', + title: '每日运动30分钟', + repeatType: 'daily', + frequency: 1, + status: 'active', + completedCount: 5, + targetCount: 30, + hasReminder: true, + reminderTime: '09:00', + category: '运动', + priority: 5, + startDate: '2024-01-01', + startTime: 900, + endTime: 1800, + progressPercentage: 17, + }, + { + id: 'mock-2', + userId: 'test-user-1', + title: '每天喝8杯水', + repeatType: 'daily', + frequency: 8, + status: 'active', + completedCount: 6, + targetCount: 8, + hasReminder: true, + reminderTime: '10:00', + category: '健康', + priority: 8, + startDate: '2024-01-01', + startTime: 600, + endTime: 2200, + progressPercentage: 75, + }, + { + id: 'mock-3', + userId: 'test-user-1', + title: '每周读书2小时', + repeatType: 'weekly', + frequency: 2, + status: 'paused', + completedCount: 1, + targetCount: 2, + hasReminder: false, + category: '学习', + priority: 3, + startDate: '2024-01-01', + startTime: 800, + endTime: 2000, + progressPercentage: 50, + }, + ]; + + // 直接更新 Redux 状态(仅用于开发测试) + dispatch({ + type: 'goals/fetchGoals/fulfilled', + payload: { + query: { page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc' }, + response: { + list: mockGoals, + page: 1, + pageSize: 20, + total: mockGoals.length, + } + } + }); + } + } + }; + + // 下拉刷新 + const onRefresh = async () => { + setRefreshing(true); + try { + await loadGoals(); + } finally { + setRefreshing(false); + } + }; + + // 加载更多目标 + const handleLoadMoreGoals = async () => { + if (goalsPagination.hasMore && !goalsLoading) { + try { + await dispatch(loadMoreGoals()).unwrap(); + } catch (error) { + console.error('Failed to load more goals:', error); + } + } + }; + + // 处理删除目标 + const handleDeleteGoal = async (goalId: string) => { + try { + await dispatch(deleteGoal(goalId)).unwrap(); + // 删除成功,Redux 会自动更新状态 + } catch (error) { + console.error('Failed to delete goal:', error); + Alert.alert('错误', '删除目标失败,请重试'); + } + }; + + // 处理错误提示 + useEffect(() => { + if (goalsError) { + Alert.alert('错误', goalsError); + } + }, [goalsError]); + + // 计算各状态的目标数量 + const goalCounts = useMemo(() => ({ + all: goals.length, + active: goals.filter(goal => goal.status === 'active').length, + paused: goals.filter(goal => goal.status === 'paused').length, + completed: goals.filter(goal => goal.status === 'completed').length, + cancelled: goals.filter(goal => goal.status === 'cancelled').length, + }), [goals]); + + // 根据筛选条件过滤目标 + const filteredGoals = useMemo(() => { + return goals; + }, [goals]); + + + + // 处理目标点击 + const handleGoalPress = (goal: GoalListItem) => { + }; + + // 渲染目标项 + const renderGoalItem = ({ item }: { item: GoalListItem }) => ( + + ); + + // 渲染空状态 + const renderEmptyState = () => { + let title = '暂无目标'; + let subtitle = '创建您的第一个目标,开始您的健康之旅'; + + + + return ( + + + + {title} + + + {subtitle} + + router.push('/(tabs)/goals')} + > + 创建目标 + + + ); + }; + + + + // 渲染加载更多 + const renderLoadMore = () => { + if (!goalsPagination.hasMore) return null; + return ( + + + {goalsLoading ? '加载中...' : '上拉加载更多'} + + + ); + }; + + return ( + + + + {/* 背景渐变 */} + + + + {/* 标题区域 */} + + router.back()} + > + + + + 目标列表 + + router.push('/(tabs)/goals')} + > + + + + + + {/* 目标列表 */} + + item.id} + contentContainerStyle={styles.goalsList} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={handleLoadMoreGoals} + onEndReachedThreshold={0.1} + ListEmptyComponent={renderEmptyState} + ListFooterComponent={renderLoadMore} + /> + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + content: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 16, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + pageTitle: { + fontSize: 24, + fontWeight: '700', + flex: 1, + textAlign: 'center', + }, + addButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#7A5AF8', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + goalsListContainer: { + flex: 1, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: 'hidden', + }, + goalsList: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 80, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + marginTop: 16, + marginBottom: 8, + }, + emptyStateSubtitle: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + marginBottom: 24, + paddingHorizontal: 40, + }, + createButton: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + createButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + loadMoreContainer: { + alignItems: 'center', + paddingVertical: 20, + }, + loadMoreText: { + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/app/task-list.tsx b/app/task-list.tsx new file mode 100644 index 0000000..7ba8d89 --- /dev/null +++ b/app/task-list.tsx @@ -0,0 +1,288 @@ +import { DateSelector } from '@/components/DateSelector'; +import { TaskCard } from '@/components/TaskCard'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { tasksApi } from '@/services/tasksApi'; +import { TaskListItem } from '@/types/goals'; +import { getTodayIndexInMonth } from '@/utils/date'; +import { useFocusEffect } from '@react-navigation/native'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native'; + +export default function GoalsDetailScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const router = useRouter(); + + // 本地状态管理 + const [tasks, setTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(false); + const [tasksError, setTasksError] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [refreshing, setRefreshing] = useState(false); + + // 日期选择器相关状态 + const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); + + // 加载任务列表 + const loadTasks = async (targetDate?: Date) => { + try { + setTasksLoading(true); + setTasksError(null); + + const dateToUse = targetDate || selectedDate; + console.log('Loading tasks for date:', dayjs(dateToUse).format('YYYY-MM-DD')); + + const response = await tasksApi.getTasks({ + startDate: dayjs(dateToUse).startOf('day').toISOString(), + endDate: dayjs(dateToUse).endOf('day').toISOString(), + }); + + console.log('Tasks API response:', response); + setTasks(response.list || []); + } catch (error: any) { + console.error('Failed to load tasks:', error); + setTasksError(error.message || '获取任务列表失败'); + setTasks([]); + } finally { + setTasksLoading(false); + } + }; + + // 页面聚焦时重新加载数据 + useFocusEffect( + useCallback(() => { + console.log('useFocusEffect - loading tasks'); + loadTasks(); + }, []) + ); + + // 下拉刷新 + const onRefresh = async () => { + setRefreshing(true); + try { + await loadTasks(); + } finally { + setRefreshing(false); + } + }; + + // 处理错误提示 + useEffect(() => { + if (tasksError) { + Alert.alert('错误', tasksError); + setTasksError(null); + } + }, [tasksError]); + + + + // 日期选择处理 + const onSelectDate = async (index: number, date: Date) => { + console.log('Date selected:', dayjs(date).format('YYYY-MM-DD')); + setSelectedIndex(index); + setSelectedDate(date); + // 重新加载对应日期的任务数据 + await loadTasks(date); + }; + + // 根据选中日期筛选任务,并将已完成的任务放到最后 + const filteredTasks = useMemo(() => { + const selected = dayjs(selectedDate); + const filtered = tasks.filter(task => { + if (task.status === 'skipped') return false; + const taskDate = dayjs(task.startDate); + return taskDate.isSame(selected, 'day'); + }); + + // 对筛选结果进行排序:已完成的任务放到最后 + return [...filtered].sort((a, b) => { + const aCompleted = a.status === 'completed'; + const bCompleted = b.status === 'completed'; + + // 如果a已完成而b未完成,a排在后面 + if (aCompleted && !bCompleted) { + return 1; + } + // 如果b已完成而a未完成,b排在后面 + if (bCompleted && !aCompleted) { + return -1; + } + // 如果都已完成或都未完成,保持原有顺序 + return 0; + }); + }, [selectedDate, tasks]); + + const handleBackPress = () => { + router.back(); + }; + + // 渲染任务项 + const renderTaskItem = ({ item }: { item: TaskListItem }) => ( + + ); + + // 渲染空状态 + const renderEmptyState = () => { + const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日'); + + if (tasksLoading) { + return ( + + + 加载中... + + + ); + } + + return ( + + + 暂无任务 + + + {selectedDateStr} 没有任务安排 + + + ); + }; + + return ( + + + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + + {/* 标题区域 */} + + + {/* 日期选择器 */} + + + + + {/* 任务列表 */} + + item.id} + contentContainerStyle={styles.taskList} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={renderEmptyState} + /> + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + opacity: 0.6, + }, + decorativeCircle1: { + position: 'absolute', + top: -20, + right: -20, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#0EA5E9', + opacity: 0.1, + }, + decorativeCircle2: { + position: 'absolute', + bottom: -15, + left: -15, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#0EA5E9', + opacity: 0.05, + }, + content: { + flex: 1, + }, + + // 日期选择器样式 + dateSelector: { + paddingHorizontal: 20, + paddingVertical: 16, + }, + taskListContainer: { + flex: 1, + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: 'hidden', + }, + taskList: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + emptyStateSubtitle: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/assets/images/task/icon-copy.png b/assets/images/task/icon-copy.png new file mode 100644 index 0000000..06a0d00 Binary files /dev/null and b/assets/images/task/icon-copy.png differ diff --git a/components/GoalCard.tsx b/components/GoalCard.tsx index b7ea643..b81cc2b 100644 --- a/components/GoalCard.tsx +++ b/components/GoalCard.tsx @@ -1,197 +1,289 @@ -import { Colors } from '@/constants/Colors'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { Ionicons } from '@expo/vector-icons'; -import dayjs from 'dayjs'; -import React from 'react'; -import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -export interface GoalItem { - id: string; - title: string; - description?: string; - time: string; - category: 'workout' | 'finance' | 'personal' | 'work' | 'health'; - priority?: 'high' | 'medium' | 'low'; -} +import { GoalListItem } from '@/types/goals'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import React, { useRef } from 'react'; +import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Swipeable } from 'react-native-gesture-handler'; interface GoalCardProps { - item: GoalItem; - onPress?: (item: GoalItem) => void; + goal: GoalListItem; + onPress?: (goal: GoalListItem) => void; + onDelete?: (goalId: string) => void; + showStatus?: boolean; } -const { width: screenWidth } = Dimensions.get('window'); -const CARD_WIDTH = (screenWidth - 60) * 0.65; // 显示1.5张卡片 +export const GoalCard: React.FC = ({ + goal, + onPress, + onDelete, + showStatus = true +}) => { + const swipeableRef = useRef(null); -const getCategoryIcon = (category: GoalItem['category']) => { - switch (category) { - case 'workout': - return 'fitness-outline'; - case 'finance': - return 'card-outline'; - case 'personal': - return 'person-outline'; - case 'work': - return 'briefcase-outline'; - case 'health': - return 'heart-outline'; - default: - return 'checkmark-circle-outline'; - } -}; + // 获取重复类型显示文本 + const getRepeatTypeText = (goal: GoalListItem) => { + switch (goal.repeatType) { + case 'daily': + return '每日'; + case 'weekly': + return '每周'; + case 'monthly': + return '每月'; + default: + return '每日'; + } + }; -const getCategoryColor = (category: GoalItem['category']) => { - switch (category) { - case 'workout': - return '#FF6B6B'; - case 'finance': - return '#4ECDC4'; - case 'personal': - return '#45B7D1'; - case 'work': - return '#96CEB4'; - case 'health': - return '#FFEAA7'; - default: - return '#DDA0DD'; - } -}; + // 获取目标状态显示文本 + const getStatusText = (goal: GoalListItem) => { + switch (goal.status) { + case 'active': + return '进行中'; + case 'paused': + return '已暂停'; + case 'completed': + return '已完成'; + case 'cancelled': + return '已取消'; + default: + return '进行中'; + } + }; -const getPriorityColor = (priority: GoalItem['priority']) => { - switch (priority) { - case 'high': - return '#FF4757'; - case 'medium': - return '#FFA502'; - case 'low': - return '#2ED573'; - default: - return '#747D8C'; - } -}; + // 获取目标状态颜色 + const getStatusColor = (goal: GoalListItem) => { + switch (goal.status) { + case 'active': + return '#10B981'; + case 'paused': + return '#F59E0B'; + case 'completed': + return '#3B82F6'; + case 'cancelled': + return '#EF4444'; + default: + return '#10B981'; + } + }; -export function GoalCard({ item, onPress }: GoalCardProps) { - const theme = useColorScheme() ?? 'light'; - const colorTokens = Colors[theme]; + // 获取目标图标 + const getGoalIcon = (goal: GoalListItem) => { + // 根据目标类别或标题返回不同的图标 + const title = goal.title.toLowerCase(); + const category = goal.category?.toLowerCase(); + + if (title.includes('运动') || title.includes('健身') || title.includes('跑步')) { + return 'fitness-center'; + } else if (title.includes('喝水') || title.includes('饮水')) { + return 'local-drink'; + } else if (title.includes('睡眠') || title.includes('睡觉')) { + return 'bedtime'; + } else if (title.includes('学习') || title.includes('读书')) { + return 'school'; + } else if (title.includes('冥想') || title.includes('放松')) { + return 'self-improvement'; + } else if (title.includes('早餐') || title.includes('午餐') || title.includes('晚餐')) { + return 'restaurant'; + } else { + return 'flag'; + } + }; - const categoryColor = getCategoryColor(item.category); - const categoryIcon = getCategoryIcon(item.category); - const priorityColor = getPriorityColor(item.priority); + // 处理删除操作 + const handleDelete = () => { + Alert.alert( + '确认删除', + `确定要删除目标"${goal.title}"吗?此操作无法撤销。`, + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '删除', + style: 'destructive', + onPress: () => { + onDelete?.(goal.id); + swipeableRef.current?.close(); + }, + }, + ] + ); + }; - const timeFormatted = dayjs(item.time).format('HH:mm'); + // 渲染删除按钮 + const renderRightActions = () => { + return ( + + + + ); + }; return ( - onPress?.(item)} - activeOpacity={0.8} + - {/* 顶部标签和优先级 */} - - - - {item.category} + onPress?.(goal)} + activeOpacity={0.7} + > + {/* 左侧图标 */} + + + + + + - {item.priority && ( - - )} - - - {/* 主要内容 */} - - - {item.title} - - - {item.description && ( - - {item.description} + {/* 中间内容 */} + + + {goal.title} - )} - + + {/* 底部信息行 */} + + {/* 积分 */} + + +1 + - {/* 底部时间 */} - - - - - {timeFormatted} - + {/* 目标数量 */} + + + {goal.targetCount || goal.frequency} + + + + {/* 提醒图标(如果有提醒) */} + {goal.hasReminder && ( + + + + )} + + {/* 提醒时间(如果有提醒) */} + {goal.hasReminder && goal.reminderTime && ( + + {goal.reminderTime} + + )} + + {/* 重复图标 */} + + + + + {/* 重复类型 */} + + {getRepeatTypeText(goal)} + + - - + + {/* 右侧状态指示器 */} + {showStatus && ( + + {getStatusText(goal)} + + )} + + ); -} +}; const styles = StyleSheet.create({ - container: { - width: CARD_WIDTH, - height: 140, - marginHorizontal: 8, - borderRadius: 20, + goalCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderRadius: 16, padding: 16, - elevation: 6, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, + }, + goalIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, position: 'relative', }, - header: { + iconStars: { + position: 'absolute', + top: -2, + right: -2, flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, + gap: 1, }, - categoryBadge: { + star: { + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: '#FFFFFF', + }, + goalContent: { + flex: 1, + marginRight: 12, + }, + goalTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1F2937', + marginBottom: 8, + }, + goalInfo: { flexDirection: 'row', alignItems: 'center', + gap: 8, + }, + infoItem: { + flexDirection: 'row', + alignItems: 'center', + }, + infoText: { + fontSize: 12, + color: '#9CA3AF', + fontWeight: '500', + }, + statusIndicator: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, - backgroundColor: '#4ECDC4', + minWidth: 60, + alignItems: 'center', }, - categoryText: { + statusText: { fontSize: 10, + color: '#FFFFFF', fontWeight: '600', - color: '#fff', - marginLeft: 4, - textTransform: 'capitalize', }, - priorityDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#FF4757', - }, - content: { - flex: 1, + deleteButton: { + width: 60, + height: '100%', justifyContent: 'center', - }, - title: { - fontSize: 16, - fontWeight: '700', - lineHeight: 20, - marginBottom: 4, - }, - description: { - fontSize: 12, - lineHeight: 16, - opacity: 0.7, - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 12, - }, - timeContainer: { - flexDirection: 'row', alignItems: 'center', }, - timeText: { + deleteButtonText: { + color: '#FFFFFF', fontSize: 12, - fontWeight: '500', - marginLeft: 4, + fontWeight: '600', + marginTop: 4, }, }); diff --git a/components/TaskCard.tsx b/components/TaskCard.tsx index 8f664e6..e9992f9 100644 --- a/components/TaskCard.tsx +++ b/components/TaskCard.tsx @@ -4,7 +4,6 @@ import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { completeTask, skipTask } from '@/store/tasksSlice'; import { TaskListItem } from '@/types/goals'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useRouter } from 'expo-router'; import React from 'react'; import { Alert, Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -27,7 +26,7 @@ export const TaskCard: React.FC = ({ // 当任务进度变化时,启动动画 React.useEffect(() => { - const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 2; + const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 6; Animated.timing(progressAnimation, { toValue: targetProgress, @@ -36,7 +35,6 @@ export const TaskCard: React.FC = ({ }).start(); }, [task.progressPercentage, progressAnimation]); - const getStatusText = (status: string) => { switch (status) { case 'completed': @@ -179,51 +177,93 @@ export const TaskCard: React.FC = ({ ); }; + + return ( - {/* 头部区域 */} - - - + + {/* 左侧图标区域 */} + + + + + + + {/* 右侧信息区域 */} + + {/* 任务标题 */} + {task.title} - {renderActionIcons()} + + {/* 进度条 */} + + {/* 背景进度条 */} + + + {/* 实际进度条 */} + 0 + ? '#8B5CF6' + : '#C7D2FE', // 浅紫色,表示待开始 + }, + ]} + /> + + {/* 进度文字 */} + 20 || task.status === 'completed' + ? '#FFFFFF' + : '#374151', // 进度较少时使用深色文字 + } + ]}> + {task.currentCount}/{task.targetCount} 次 + + - - {/* 状态和优先级标签 */} - - - - {getStatusText(task.status)} - - - - {/* 进度条 */} - - 0 ? colorTokens.primary : '#E5E7EB', - }, - ]} - /> - {task.progressPercentage > 0 && task.progressPercentage < 100 && ( - - )} - {/* 进度百分比文本 */} - - {task.currentCount}/{task.targetCount} - + {/* 操作按钮 */} + {renderActionIcons()} ); @@ -231,8 +271,8 @@ export const TaskCard: React.FC = ({ const styles = StyleSheet.create({ container: { - padding: 16, - borderRadius: 12, + padding: 14, + borderRadius: 30, marginBottom: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, @@ -240,6 +280,91 @@ const styles = StyleSheet.create({ shadowRadius: 4, elevation: 3, }, + cardContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + iconSection: { + flexShrink: 0, + }, + iconCircle: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#F3E8FF', // 浅紫色背景 + alignItems: 'center', + justifyContent: 'center', + }, + infoSection: { + flex: 1, + gap: 8, + }, + title: { + fontSize: 15, + fontWeight: '600', + lineHeight: 20, + color: '#1F2937', // 深蓝紫色文字 + marginBottom: 2, + }, + progressContainer: { + position: 'relative', + height: 14, + justifyContent: 'center', + marginTop: 4, + }, + progressBackground: { + position: 'absolute', + left: 0, + right: 0, + height: 14, + backgroundColor: '#F3F4F6', + borderRadius: 10, + }, + progressBar: { + height: 14, + borderRadius: 10, + position: 'absolute', + left: 0, + minWidth: '6%', // 确保最小宽度可见 + }, + progressText: { + fontSize: 12, + fontWeight: '600', + color: '#FFFFFF', + textAlign: 'center', + position: 'absolute', + left: 0, + right: 0, + zIndex: 1, + }, + actionIconsContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + flexShrink: 0, + }, + iconContainer: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F3F4F6', + }, + skipIconContainer: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', + }, + taskIcon: { + width: 18, + height: 18, + }, + // 保留其他样式以备后用 header: { marginBottom: 12, }, @@ -248,38 +373,6 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 12, }, - title: { - fontSize: 16, - fontWeight: '600', - lineHeight: 22, - flex: 1, - }, - actionIconsContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - flexShrink: 0, - }, - iconContainer: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#F3F4F6', - }, - skipIconContainer: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: '#F3F4F6', - alignItems: 'center', - justifyContent: 'center', - }, - taskIcon: { - width: 20, - height: 20, - }, tagsContainer: { flexDirection: 'row', gap: 8, @@ -312,7 +405,7 @@ const styles = StyleSheet.create({ fontWeight: '500', color: '#FFFFFF', }, - progressBar: { + progressBarOld: { height: 6, backgroundColor: '#F3F4F6', borderRadius: 3, @@ -360,11 +453,6 @@ const styles = StyleSheet.create({ borderColor: '#E5E7EB', zIndex: 1, }, - progressText: { - fontSize: 10, - fontWeight: '600', - color: '#374151', - }, footer: { flexDirection: 'row', justifyContent: 'space-between', @@ -391,10 +479,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, - infoSection: { - flexDirection: 'row', - gap: 8, - }, infoTag: { flexDirection: 'row', alignItems: 'center', @@ -409,3 +493,4 @@ const styles = StyleSheet.create({ color: '#374151', }, }); + diff --git a/components/TaskProgressCard.tsx b/components/TaskProgressCard.tsx index dd981dd..35dd1ce 100644 --- a/components/TaskProgressCard.tsx +++ b/components/TaskProgressCard.tsx @@ -1,7 +1,8 @@ import { TaskListItem } from '@/types/goals'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useRouter } from 'expo-router'; import React, { ReactNode } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; interface TaskProgressCardProps { tasks: TaskListItem[]; @@ -12,23 +13,39 @@ export const TaskProgressCard: React.FC = ({ tasks, headerButtons, }) => { + const router = useRouter(); + // 计算各状态的任务数量 const pendingTasks = tasks.filter(task => task.status === 'pending'); const completedTasks = tasks.filter(task => task.status === 'completed'); const skippedTasks = tasks.filter(task => task.status === 'skipped'); + // 处理跳转到目标列表 + const handleNavigateToGoals = () => { + router.push('/goals-list'); + }; + return ( {/* 标题区域 */} 统计 + + + + + + + {headerButtons && ( + + {headerButtons} + + )} - {headerButtons && ( - - {headerButtons} - - )} {/* 状态卡片区域 */} @@ -85,15 +102,27 @@ const styles = StyleSheet.create({ }, titleContainer: { flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 4, }, headerButtons: { flexDirection: 'row', alignItems: 'center', gap: 8, }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + goalsIconButton: { + }, title: { fontSize: 20, fontWeight: '700', + lineHeight: 24, + height: 24, color: '#1F2937', marginBottom: 4, }, diff --git a/components/statistic/OxygenSaturationCard.tsx b/components/statistic/OxygenSaturationCard.tsx index 8090e2e..be69c59 100644 --- a/components/statistic/OxygenSaturationCard.tsx +++ b/components/statistic/OxygenSaturationCard.tsx @@ -21,7 +21,7 @@ const OxygenSaturationCard: React.FC = ({ return ( + + {/* 原有的卡片内容 */} + + +``` + +## 样式设计 + +### 删除按钮样式 +```typescript +deleteButton: { + backgroundColor: '#EF4444', + justifyContent: 'center', + alignItems: 'center', + width: 80, + height: '100%', + borderTopRightRadius: 16, + borderBottomRightRadius: 16, + marginBottom: 12, +} +``` + +### 删除按钮文字样式 +```typescript +deleteButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '600', + marginTop: 4, +} +``` + +## 注意事项 + +1. **手势处理**: 必须使用 `GestureHandlerRootView` 包装应用 +2. **类型安全**: 确保所有必要的字段都正确设置 +3. **错误处理**: 删除失败时显示错误提示 +4. **用户体验**: 提供确认对话框防止误删 +5. **性能优化**: 使用 `useRef` 管理 Swipeable 引用 + +## 扩展功能 + +可以考虑添加的其他功能: +- 批量删除 +- 撤销删除 +- 删除动画效果 +- 更多操作按钮(编辑、暂停等) + +## 测试建议 + +1. **手势测试**: 测试不同滑动距离和速度 +2. **边界测试**: 测试快速连续滑动 +3. **状态测试**: 测试删除后的列表更新 +4. **错误测试**: 测试网络错误时的处理 +5. **UI测试**: 测试不同屏幕尺寸下的显示效果 diff --git a/docs/goals-list-implementation.md b/docs/goals-list-implementation.md new file mode 100644 index 0000000..5a2b478 --- /dev/null +++ b/docs/goals-list-implementation.md @@ -0,0 +1,158 @@ +# 目标列表功能实现文档 + +## 功能概述 + +基于图片中的习惯列表样式,我们创建了一个全新的目标列表页面,高保真还原了设计稿的视觉效果,并将内容替换为目标相关的内容。 + +## 新增文件 + +### 1. 目标列表页面 (`app/goals-list.tsx`) +- 完整的目标列表展示 +- 支持筛选不同状态的目标 +- 下拉刷新和加载更多功能 +- 响应式设计和主题适配 + +### 2. 目标卡片组件 (`components/GoalCard.tsx`) +- 可复用的目标卡片组件 +- 支持显示目标状态、重复类型、提醒等信息 +- 智能图标选择(根据目标内容自动选择合适图标) +- 支持点击事件和状态显示控制 + +### 3. 目标筛选组件 (`components/GoalFilterTabs.tsx`) +- 支持按状态筛选目标(全部、进行中、已暂停、已完成、已取消) +- 显示各状态的目标数量 +- 美观的标签式设计 + +### 4. 目标详情页面 (`app/goal-detail.tsx`) +- 显示单个目标的详细信息 +- 目标统计信息(已完成、目标、进度) +- 目标属性信息(状态、重复类型、时间等) +- 完成记录列表 + +## 设计特点 + +### 视觉设计 +- **高保真还原**: 完全按照图片中的习惯列表样式设计 +- **卡片式布局**: 白色圆角卡片,带有阴影效果 +- **图标设计**: 紫色主题图标,带有白色星星装饰 +- **信息层次**: 清晰的信息层次结构,主标题 + 详细信息行 + +### 交互设计 +- **点击反馈**: 卡片点击有视觉反馈 +- **筛选功能**: 支持按状态筛选目标 +- **导航流畅**: 支持导航到目标详情页面 +- **下拉刷新**: 支持下拉刷新数据 + +## 数据结构映射 + +### 图片中的习惯信息 → 目标信息 +| 图片元素 | 目标对应 | 说明 | +|---------|---------|------| +| 习惯标题 | 目标标题 | 显示目标名称 | +| +1 | 积分 | 显示目标完成获得的积分 | +| 目标数量 | 目标数量/频率 | 显示目标的目标数量或频率 | +| 提醒图标 | 提醒设置 | 如果目标有提醒则显示 | +| 提醒时间 | 提醒时间 | 显示具体的提醒时间 | +| 重复图标 | 重复类型 | 显示循环图标 | +| 重复类型 | 重复类型 | 显示"每日"、"每周"、"每月" | + +## 功能特性 + +### 1. 智能图标选择 +根据目标标题和类别自动选择合适图标: +- 运动相关:`fitness-center` +- 饮水相关:`local-drink` +- 睡眠相关:`bedtime` +- 学习相关:`school` +- 冥想相关:`self-improvement` +- 饮食相关:`restaurant` +- 默认:`flag` + +### 2. 状态管理 +- 使用Redux进行状态管理 +- 支持分页加载 +- 错误处理和加载状态 +- 筛选状态保持 + +### 3. 响应式设计 +- 适配不同屏幕尺寸 +- 支持浅色/深色主题 +- 流畅的动画效果 + +## 使用方式 + +### 1. 访问目标列表 +在目标页面点击"列表"按钮,或直接访问 `/goals-list` 路由。 + +### 2. 筛选目标 +使用顶部的筛选标签,可以按状态筛选目标: +- 全部:显示所有目标 +- 进行中:显示正在进行的目标 +- 已暂停:显示已暂停的目标 +- 已完成:显示已完成的目标 +- 已取消:显示已取消的目标 + +### 3. 查看目标详情 +点击任意目标卡片,可以查看目标的详细信息。 + +### 4. 刷新数据 +下拉列表可以刷新目标数据。 + +## 技术实现 + +### 组件架构 +``` +GoalsListScreen +├── GoalFilterTabs (筛选标签) +└── FlatList + └── GoalCard (目标卡片) + ├── 图标区域 + ├── 内容区域 + └── 状态指示器 +``` + +### 状态管理 +- 使用Redux Toolkit管理目标数据 +- 支持分页和筛选状态 +- 错误处理和加载状态 + +### 样式系统 +- 使用StyleSheet进行样式管理 +- 支持主题切换 +- 响应式设计 + +## 扩展功能 + +### 1. 搜索功能 +可以添加搜索框,支持按标题搜索目标。 + +### 2. 排序功能 +可以添加排序选项,按创建时间、优先级等排序。 + +### 3. 批量操作 +可以添加批量选择、批量操作功能。 + +### 4. 统计图表 +可以添加目标完成情况的统计图表。 + +## 测试建议 + +### 1. 功能测试 +- 测试目标列表加载 +- 测试筛选功能 +- 测试点击导航 +- 测试下拉刷新 + +### 2. 样式测试 +- 测试不同屏幕尺寸 +- 测试主题切换 +- 测试长文本显示 + +### 3. 性能测试 +- 测试大量数据加载 +- 测试内存使用情况 +- 测试滚动性能 + +## 总结 + +新创建的目标列表功能完全按照设计稿实现,提供了良好的用户体验和完整的功能支持。通过模块化的组件设计,代码具有良好的可维护性和可扩展性。 diff --git a/utils/health.ts b/utils/health.ts index cf0229a..b4cbbc7 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -220,8 +220,15 @@ export async function fetchHealthDataForDate(date: Date): Promise 0 && value < 1) { + value = value * 100; + console.log('血氧饱和度数据从小数转换为百分比:', latestOxygen.value, '->', value); + } + // 血氧饱和度通常在0-100之间,验证数据有效性 - const value = Number(latestOxygen.value); if (value >= 0 && value <= 100) { resolve(Number(value.toFixed(1))); } else { @@ -346,3 +353,66 @@ export async function updateWeight(weight: number) { }); }); } + +// 新增:测试血氧饱和度数据获取 +export async function testOxygenSaturationData(date: Date = new Date()): Promise { + console.log('=== 开始测试血氧饱和度数据获取 ==='); + + const start = dayjs(date).startOf('day').toDate(); + const end = dayjs(date).endOf('day').toDate(); + + const options = { + startDate: start.toISOString(), + endDate: end.toISOString() + } as any; + + return new Promise((resolve) => { + AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => { + if (err) { + console.error('获取血氧饱和度失败:', err); + resolve(); + return; + } + + console.log('原始血氧饱和度数据:', res); + + if (!res || !Array.isArray(res) || res.length === 0) { + console.warn('血氧饱和度数据为空'); + resolve(); + return; + } + + // 分析所有数据样本 + res.forEach((sample, index) => { + console.log(`样本 ${index + 1}:`, { + value: sample.value, + valueType: typeof sample.value, + startDate: sample.startDate, + endDate: sample.endDate + }); + }); + + // 获取最新的血氧饱和度值 + const latestOxygen = res[res.length - 1]; + if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) { + let value = Number(latestOxygen.value); + + console.log('处理前的值:', latestOxygen.value); + console.log('转换为数字后的值:', value); + + // 检查数据格式:如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比 + if (value > 0 && value < 1) { + const originalValue = value; + value = value * 100; + console.log('血氧饱和度数据从小数转换为百分比:', originalValue, '->', value); + } + + console.log('最终处理后的值:', value); + console.log('数据有效性检查:', value >= 0 && value <= 100 ? '有效' : '无效'); + } + + console.log('=== 血氧饱和度数据测试完成 ==='); + resolve(); + }); + }); +}