From 4f2bd76b8f8e3279dbec1f12e34e162c9c2f0232 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sat, 23 Aug 2025 17:58:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=B7=A6=E6=BB=91=E5=88=A0=E9=99=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在目标列表中添加左滑删除功能,用户可通过左滑手势显示删除按钮并确认删除目标 - 修改 GoalCard 组件,使用 Swipeable 组件包装卡片内容,支持删除操作 - 更新目标列表页面,集成删除目标的逻辑,确保与 Redux 状态管理一致 - 添加开发模式下的模拟数据,方便测试删除功能 - 更新相关文档,详细描述左滑删除功能的实现和使用方法 --- app/(tabs)/goals.tsx | 52 +- app/(tabs)/statistics.tsx | 23 +- app/_layout.tsx | 53 ++- app/goals-detail.tsx | 449 ------------------ app/goals-list.tsx | 400 ++++++++++++++++ app/task-list.tsx | 288 +++++++++++ assets/images/task/icon-copy.png | Bin 0 -> 52281 bytes components/GoalCard.tsx | 404 ++++++++++------ components/TaskCard.tsx | 251 ++++++---- components/TaskProgressCard.tsx | 41 +- components/statistic/OxygenSaturationCard.tsx | 2 +- docs/goal-swipe-delete-implementation.md | 131 +++++ docs/goals-list-implementation.md | 158 ++++++ utils/health.ts | 72 ++- 14 files changed, 1592 insertions(+), 732 deletions(-) delete mode 100644 app/goals-detail.tsx create mode 100644 app/goals-list.tsx create mode 100644 app/task-list.tsx create mode 100644 assets/images/task/icon-copy.png create mode 100644 docs/goal-swipe-delete-implementation.md create mode 100644 docs/goals-list-implementation.md 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 0000000000000000000000000000000000000000..06a0d00ea5ca201f8e542db8b642d996f7c358c2 GIT binary patch literal 52281 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

^qqn?fBgL+RPF(93{~J* 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 literal 0 HcmV?d00001 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(); + }); + }); +}