diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index a245031..9740c61 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -1827,12 +1827,17 @@ export default function CoachScreen() { return ( + {/* 背景渐变 */} + + {/* 装饰性圆圈 */} + + {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} { - 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), - isCompleted: goal.status === 'completed', - }; -}; - -// 获取重复类型标签 -const getRepeatTypeLabel = (repeatType: string): string => { - switch (repeatType) { - case 'daily': return '每日'; - case 'weekly': return '每周'; - case 'monthly': return '每月'; - default: return '自定义'; - } -}; - -// 从目标分类获取TodoItem分类 -const getCategoryFromGoal = (category?: string): TodoItem['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'; -}; - -// 从目标优先级获取TodoItem优先级 -const getPriorityFromGoal = (priority: number): TodoItem['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', - }; -}; +import { useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; export default function GoalsScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; const dispatch = useAppDispatch(); + const router = useRouter(); // Redux状态 const { - goals, - goalsLoading, - goalsError, + tasks, + tasksLoading, + tasksError, + tasksPagination, + completeLoading, + completeError, + skipLoading, + skipError, + } = useAppSelector((state) => state.tasks); + + const { createLoading, createError } = useAppSelector((state) => state.goals); - const [selectedTab, setSelectedTab] = useState('day'); - const [selectedDate, setSelectedDate] = useState(new Date()); const [showCreateModal, setShowCreateModal] = useState(false); + const [refreshing, setRefreshing] = useState(false); // 页面聚焦时重新加载数据 useFocusEffect( useCallback(() => { - console.log('useFocusEffect'); - // 只在需要时刷新数据,比如从后台返回或从其他页面返回 - dispatch(fetchGoals({ - status: 'active', - page: 1, - pageSize: 200, - })); + console.log('useFocusEffect - loading tasks'); + loadTasks(); }, [dispatch]) ); + // 加载任务列表 + const loadTasks = async () => { + try { + await dispatch(fetchTasks({ + startDate: dayjs().startOf('day').toISOString(), + endDate: dayjs().endOf('day').toISOString(), + })).unwrap(); + } catch (error) { + console.error('Failed to load tasks:', error); + } + }; + + // 下拉刷新 + const onRefresh = async () => { + setRefreshing(true); + try { + await loadTasks(); + } finally { + setRefreshing(false); + } + }; + + // 加载更多任务 + const handleLoadMoreTasks = async () => { + if (tasksPagination.hasMore && !tasksLoading) { + try { + await dispatch(loadMoreTasks()).unwrap(); + } catch (error) { + console.error('Failed to load more tasks:', error); + } + } + }; + // 处理错误提示 useEffect(() => { - console.log('goalsError', goalsError); + console.log('tasksError', tasksError); console.log('createError', createError); - if (goalsError) { - Alert.alert('错误', goalsError); - dispatch(clearErrors()); + console.log('completeError', completeError); + console.log('skipError', skipError); + + if (tasksError) { + Alert.alert('错误', tasksError); + dispatch(clearTaskErrors()); } if (createError) { Alert.alert('创建失败', createError); dispatch(clearErrors()); } - }, [goalsError, createError, dispatch]); + if (completeError) { + Alert.alert('完成失败', completeError); + dispatch(clearTaskErrors()); + } + if (skipError) { + Alert.alert('跳过失败', skipError); + dispatch(clearTaskErrors()); + } + }, [tasksError, createError, completeError, skipError, dispatch]); // 创建目标处理函数 const handleCreateGoal = async (goalData: CreateGoalRequest) => { @@ -123,156 +113,85 @@ export default function GoalsScreen() { await dispatch(createGoal(goalData)).unwrap(); setShowCreateModal(false); Alert.alert('成功', '目标创建成功!'); + // 创建目标后重新加载任务列表 + loadTasks(); } 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()); - } + // 任务点击处理 + const handleTaskPress = (task: TaskListItem) => { + console.log('Task pressed:', task.title); + // 这里可以导航到任务详情页面 }; - // 日期选择器相关状态 (参考 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); - } - } - }; - - // 将目标转换为TodoItem数据 - const todayTodos = 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(goalToTodoItem); - }, [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 handleTodoPress = (item: TodoItem) => { - console.log('Goal pressed:', item.title); - // 这里可以导航到目标详情页面 - }; - - const handleToggleComplete = async (item: TodoItem) => { + // 完成任务处理 + const handleCompleteTask = async (task: TaskListItem) => { try { - await dispatch(completeGoal({ - goalId: item.id, + await dispatch(completeTask({ + taskId: task.id, completionData: { - completionCount: 1, - notes: '通过待办卡片完成' + count: 1, + notes: '通过任务卡片完成' } })).unwrap(); } catch (error) { - Alert.alert('错误', '记录完成失败'); + Alert.alert('错误', '完成任务失败'); } }; - const handleEventPress = (event: any) => { - console.log('Event pressed:', event.title); - // 这里可以处理时间轴事件点击 + // 跳过任务处理 + const handleSkipTask = async (task: TaskListItem) => { + try { + await dispatch(skipTask({ + taskId: task.id, + skipData: { + reason: '用户主动跳过' + } + })).unwrap(); + } catch (error) { + Alert.alert('错误', '跳过任务失败'); + } + }; + + // 导航到目标管理页面 + const handleNavigateToGoals = () => { + router.push('/goals-detail'); + }; + + // 渲染任务项 + const renderTaskItem = ({ item }: { item: TaskListItem }) => ( + + ); + + // 渲染空状态 + const renderEmptyState = () => ( + + + 暂无任务 + + + 创建目标后,系统会自动生成相应的任务 + + + ); + + // 渲染加载更多 + const renderLoadMore = () => { + if (!tasksPagination.hasMore) return null; + return ( + + + {tasksLoading ? '加载中...' : '上拉加载更多'} + + + ); }; return ( @@ -284,103 +203,61 @@ export default function GoalsScreen() { {/* 背景渐变 */} + {/* 装饰性圆圈 */} + + + {/* 标题区域 */} - 今日 + 任务 - setShowCreateModal(true)} - > - + - + + + + + setShowCreateModal(true)} + > + + + + - {/* 今日待办事项卡片 */} - - {/* 时间筛选选项卡 */} - + {/* 任务进度卡片 */} + - {/* 日期选择器 - 在周和月模式下显示 */} - {(selectedTab === 'week' || selectedTab === 'month') && ( - - - {monthTitle} - - setScrollWidth(e.nativeEvent.layout.width)} - > - {days.map((d, i) => { - const selected = i === selectedIndex; - const isFutureDate = d.date.isAfter(dayjs(), 'day'); - - // 根据选择的tab模式决定是否显示该日期 - let shouldShow = true; - if (selectedTab === 'week') { - // 周模式:只显示选中日期所在周的日期 - const selectedWeekStart = dayjs(selectedDate).startOf('week'); - const selectedWeekEnd = dayjs(selectedDate).endOf('week'); - shouldShow = d.date.isBetween(selectedWeekStart, selectedWeekEnd, 'day', '[]'); - } - - if (!shouldShow) return null; - - return ( - - !isFutureDate && onSelectDate(i)} - activeOpacity={isFutureDate ? 1 : 0.8} - disabled={isFutureDate} - > - {d.weekdayZh} - {d.dayOfMonth} - - - ); - })} - - - )} - - {/* 时间轴安排 */} - - + item.id} + contentContainerStyle={styles.taskList} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={handleLoadMoreTasks} + onEndReachedThreshold={0.1} + ListEmptyComponent={renderEmptyState} + ListFooterComponent={renderLoadMore} /> @@ -400,12 +277,33 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - backgroundGradient: { + 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, @@ -418,16 +316,35 @@ const styles = StyleSheet.create({ paddingTop: 20, paddingBottom: 16, }, + headerButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + goalsButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, pageTitle: { fontSize: 28, fontWeight: '800', marginBottom: 4, }, addButton: { - width: 40, - height: 40, + width: 30, + height: 30, borderRadius: 20, - backgroundColor: '#6366F1', + backgroundColor: '#0EA5E9', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -438,80 +355,44 @@ const styles = StyleSheet.create({ }, addButtonText: { color: '#FFFFFF', - fontSize: 24, + fontSize: 22, fontWeight: '600', - lineHeight: 24, + lineHeight: 22, }, - pageSubtitle: { - fontSize: 16, - fontWeight: '500', - }, - timelineSection: { + + taskListContainer: { flex: 1, backgroundColor: 'rgba(255, 255, 255, 0.95)', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', }, - // 日期选择器样式 (参考 statistics.tsx) - dateSelector: { + taskList: { paddingHorizontal: 20, - paddingVertical: 16, + paddingTop: 20, + paddingBottom: 20, }, - monthTitle: { - fontSize: 24, - fontWeight: '800', - marginBottom: 14, - }, - daysContainer: { - paddingBottom: 8, - }, - dayItemWrapper: { - alignItems: 'center', - width: 48, - marginRight: 8, - }, - dayPill: { - width: 40, - height: 60, - borderRadius: 24, + emptyState: { alignItems: 'center', justifyContent: 'center', + paddingVertical: 60, }, - dayPillNormal: { - backgroundColor: 'transparent', + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, }, - dayPillSelected: { - backgroundColor: '#FFFFFF', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, + emptyStateSubtitle: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, }, - dayPillDisabled: { - backgroundColor: 'transparent', - opacity: 0.4, + loadMoreContainer: { + alignItems: 'center', + paddingVertical: 20, }, - dayLabel: { - fontSize: 11, - fontWeight: '700', - color: 'gray', - marginBottom: 2, - }, - dayLabelSelected: { - color: '#192126', - }, - dayLabelDisabled: { - }, - dayDate: { - fontSize: 12, - fontWeight: '800', - color: 'gray', - }, - dayDateSelected: { - color: '#192126', - }, - dayDateDisabled: { + loadMoreText: { + fontSize: 14, + fontWeight: '500', }, }); diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 5e24f75..385280c 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -1,11 +1,14 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; +import { DateSelector } from '@/components/DateSelector'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { ProgressBar } from '@/components/ProgressBar'; import { StressMeter } from '@/components/StressMeter'; import { WeightHistoryCard } from '@/components/WeightHistoryCard'; +import HeartRateCard from '@/components/statistic/HeartRateCard'; +import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -13,7 +16,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; -import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; +import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; @@ -27,8 +30,7 @@ import { ScrollView, StyleSheet, Text, - TouchableOpacity, - View, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -92,7 +94,6 @@ export default function ExploreScreen() { const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); // 使用 dayjs:当月日期与默认选中"今天" - const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const tabBarHeight = useBottomTabBarHeight(); const insets = useSafeAreaInsets(); @@ -100,43 +101,6 @@ export default function ExploreScreen() { return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); }, [tabBarHeight, insets?.bottom]); - 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]); - // HealthKit: 每次页面聚焦都拉取今日数据 const [stepCount, setStepCount] = useState(null); const [activeCalories, setActiveCalories] = useState(null); @@ -170,16 +134,22 @@ export default function ExploreScreen() { const dispatch = useAppDispatch(); const [isMoodLoading, setIsMoodLoading] = useState(false); - // 从 Redux 获取当前日期的心情记录 - const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate( - days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD') - )); - // 记录最近一次请求的"日期键",避免旧请求覆盖新结果 const latestRequestKeyRef = useRef(null); const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; + // 获取当前选中日期 + const getCurrentSelectedDate = () => { + const days = getMonthDaysZh(); + return days[selectedIndex]?.date?.toDate() ?? new Date(); + }; + + // 从 Redux 获取当前日期的心情记录 + const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate( + dayjs(getCurrentSelectedDate()).format('YYYY-MM-DD') + )); + // 加载心情数据 const loadMoodData = async (targetDate?: Date) => { if (!isLoggedIn) return; @@ -192,7 +162,7 @@ export default function ExploreScreen() { if (targetDate) { derivedDate = targetDate; } else { - derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); + derivedDate = getCurrentSelectedDate(); } const dateString = dayjs(derivedDate).format('YYYY-MM-DD'); @@ -223,7 +193,7 @@ export default function ExploreScreen() { if (targetDate) { derivedDate = targetDate; } else { - derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); + derivedDate = getCurrentSelectedDate(); } const requestKey = getDateKey(derivedDate); @@ -278,7 +248,7 @@ export default function ExploreScreen() { if (targetDate) { derivedDate = targetDate; } else { - derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); + derivedDate = getCurrentSelectedDate(); } console.log('加载营养数据...', derivedDate); @@ -306,7 +276,7 @@ export default function ExploreScreen() { useFocusEffect( React.useCallback(() => { // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 - const currentDate = days[selectedIndex]?.date?.toDate(); + const currentDate = getCurrentSelectedDate(); if (currentDate) { loadHealthData(currentDate); if (isLoggedIn) { @@ -318,15 +288,12 @@ export default function ExploreScreen() { ); // 日期点击时,加载对应日期数据 - const onSelectDate = (index: number) => { + const onSelectDate = (index: number, date: Date) => { setSelectedIndex(index); - const target = days[index]?.date?.toDate(); - if (target) { - loadHealthData(target); - if (isLoggedIn) { - loadNutritionData(target); - loadMoodData(target); - } + loadHealthData(date); + if (isLoggedIn) { + loadNutritionData(date); + loadMoodData(date); } }; @@ -335,12 +302,18 @@ export default function ExploreScreen() { return ( + {/* 背景渐变 */} + + {/* 装饰性圆圈 */} + + + 健康数据 - {/* 标题与日期选择 */} - {monthTitle} - setScrollWidth(e.nativeEvent.layout.width)} - > - {days.map((d, i) => { - const selected = i === selectedIndex; - const isFutureDate = d.date.isAfter(dayjs(), 'day'); - return ( - - !isFutureDate && onSelectDate(i)} - activeOpacity={isFutureDate ? 1 : 0.8} - disabled={isFutureDate} - > - {d.weekdayZh} - {d.dayOfMonth} - - {selected && } - - ); - })} - + {/* 日期选择器 */} + {/* 营养摄入雷达图卡片 */} + {/* 血氧饱和度卡片 */} + + + + + {/* 心率卡片 */} + + + + @@ -514,6 +470,26 @@ const styles = StyleSheet.create({ top: 0, bottom: 0, }, + decorativeCircle1: { + position: 'absolute', + top: 40, + 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, + }, safeArea: { flex: 1, }, @@ -521,70 +497,8 @@ const styles = StyleSheet.create({ flex: 1, paddingHorizontal: 20, }, - monthTitle: { - fontSize: 24, - fontWeight: '800', - color: '#192126', - marginTop: 8, - marginBottom: 14, - }, - daysContainer: { - paddingBottom: 8, - }, - dayItemWrapper: { - alignItems: 'center', - width: 48, - marginRight: 8, - }, - dayPill: { - width: 48, - height: 48, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - }, - dayPillNormal: { - backgroundColor: lightColors.datePickerNormal, - }, - dayPillSelected: { - backgroundColor: lightColors.datePickerSelected, - }, - dayPillDisabled: { - backgroundColor: '#F5F5F5', - opacity: 0.5, - }, - dayLabel: { - fontSize: 12, - fontWeight: '700', - color: '#192126', - marginBottom: 1, - }, - dayLabelSelected: { - color: '#FFFFFF', - }, - dayLabelDisabled: { - color: '#9AA3AE', - }, - dayDate: { - fontSize: 12, - fontWeight: '800', - color: '#192126', - }, - dayDateSelected: { - color: '#FFFFFF', - }, - dayDateDisabled: { - color: '#9AA3AE', - }, - selectedDot: { - width: 5, - height: 5, - borderRadius: 2.5, - backgroundColor: lightColors.datePickerSelected, - marginTop: 6, - marginBottom: 2, - alignSelf: 'center', - }, + + sectionTitle: { fontSize: 24, fontWeight: '800', diff --git a/app/goals-detail.tsx b/app/goals-detail.tsx new file mode 100644 index 0000000..c9fa75a --- /dev/null +++ b/app/goals-detail.tsx @@ -0,0 +1,465 @@ +import CreateGoalModal from '@/components/CreateGoalModal'; +import { DateSelector } from '@/components/DateSelector'; +import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector'; +import { TimelineSchedule } from '@/components/TimelineSchedule'; +import { TodoItem } from '@/components/TodoCard'; +import { TodoCarousel } from '@/components/TodoCarousel'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { clearErrors, completeGoal, 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); + +// 将目标转换为TodoItem的辅助函数 +const goalToTodoItem = (goal: GoalListItem): TodoItem => { + 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), + isCompleted: goal.status === 'completed', + }; +}; + +// 获取重复类型标签 +const getRepeatTypeLabel = (repeatType: string): string => { + switch (repeatType) { + case 'daily': return '每日'; + case 'weekly': return '每周'; + case 'monthly': return '每月'; + default: return '自定义'; + } +}; + +// 从目标分类获取TodoItem分类 +const getCategoryFromGoal = (category?: string): TodoItem['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'; +}; + +// 从目标优先级获取TodoItem优先级 +const getPriorityFromGoal = (priority: number): TodoItem['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); + } + } + }; + + // 将目标转换为TodoItem数据 + const todayTodos = 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(goalToTodoItem); + }, [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 handleTodoPress = (item: TodoItem) => { + console.log('Goal pressed:', item.title); + // 这里可以导航到目标详情页面 + }; + + const handleToggleComplete = async (item: TodoItem) => { + try { + await dispatch(completeGoal({ + goalId: item.id, + completionData: { + completionCount: 1, + notes: '通过待办卡片完成' + } + })).unwrap(); + } catch (error) { + Alert.alert('错误', '记录完成失败'); + } + }; + + 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/components/BasalMetabolismCard.tsx b/components/BasalMetabolismCard.tsx index e84c582..ce6ec7a 100644 --- a/components/BasalMetabolismCard.tsx +++ b/components/BasalMetabolismCard.tsx @@ -15,7 +15,7 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis if (value === null || value === 0) { return { text: '未知', color: '#9AA3AE' }; } - + // 基于常见的基础代谢范围来判断状态 if (value >= 1800) { return { text: '高代谢', color: '#10B981' }; @@ -39,15 +39,14 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} /> - + {/* 装饰性圆圈 */} - + {/* 头部区域 */} - 基础代谢 diff --git a/components/CreateGoalModal.tsx b/components/CreateGoalModal.tsx index 3e48c9d..952a88e 100644 --- a/components/CreateGoalModal.tsx +++ b/components/CreateGoalModal.tsx @@ -1,7 +1,9 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals'; +import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; +import { LinearGradient } from 'expo-linear-gradient'; import React, { useState } from 'react'; import { Alert, @@ -15,6 +17,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import WheelPickerExpo from 'react-native-wheel-picker-expo'; interface CreateGoalModalProps { visible: boolean; @@ -30,7 +33,7 @@ const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [ { value: 'custom', label: '自定义' }, ]; -const FREQUENCY_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const FREQUENCY_OPTIONS = Array.from({ length: 30 }, (_, i) => i + 1); export const CreateGoalModal: React.FC = ({ visible, @@ -47,6 +50,8 @@ export const CreateGoalModal: React.FC = ({ const [repeatType, setRepeatType] = useState('daily'); const [frequency, setFrequency] = useState(1); const [hasReminder, setHasReminder] = useState(false); + const [showFrequencyPicker, setShowFrequencyPicker] = useState(false); + const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false); const [reminderTime, setReminderTime] = useState('20:00'); const [category, setCategory] = useState(''); const [priority, setPriority] = useState(5); @@ -163,12 +168,21 @@ export const CreateGoalModal: React.FC = ({ onRequestClose={handleClose} > + {/* 渐变背景 */} + + + {/* 装饰性圆圈 */} + + {/* 头部 */} - - ← - + 创建新目标 @@ -180,13 +194,13 @@ export const CreateGoalModal: React.FC = ({ {/* 目标标题输入 */} - + {/* 图标 - + */} = ({ {/* 目标重复周期 */} - - - 🔄 - - - 目标重复周期 - - + setShowRepeatTypePicker(true)}> + + + 🔄 + + + 目标重复周期 + {REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label} - - + + + {/* 重复周期选择器弹窗 */} + setShowRepeatTypePicker(false)} + > + setShowRepeatTypePicker(false)} + /> + + + opt.value === repeatType)} + items={REPEAT_TYPE_OPTIONS.map(opt => ({ label: opt.label, value: opt.value }))} + onChange={({ item }) => setRepeatType(item.value)} + backgroundColor={colorTokens.card} + haptics + /> + + {Platform.OS === 'ios' && ( + + setShowRepeatTypePicker(false)} + > + 取消 + + setShowRepeatTypePicker(false)} + > + 确定 + + + )} + + + {/* 频率设置 */} - - - 📊 - - - 频率 - - + setShowFrequencyPicker(true)}> + + + + 📊 + + + 频率 + {frequency} - - + + + {/* 频率选择器弹窗 */} + setShowFrequencyPicker(false)} + > + setShowFrequencyPicker(false)} + /> + + + ({ label: num.toString(), value: num }))} + onChange={({ item }) => setFrequency(item.value)} + backgroundColor={colorTokens.card} + // selectedStyle={{ borderColor: colorTokens.primary, borderWidth: 2 }} + haptics + /> + + {Platform.OS === 'ios' && ( + + setShowFrequencyPicker(false)} + > + 取消 + + setShowFrequencyPicker(false)} + > + 确定 + + + )} + + + {/* 提醒设置 */} @@ -354,6 +456,34 @@ export const CreateGoalModal: React.FC = ({ }; const styles = StyleSheet.create({ + 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, + }, container: { flex: 1, }, @@ -378,15 +508,13 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, }, section: { - marginBottom: 24, }, iconTitleContainer: { flexDirection: 'row', alignItems: 'flex-start', backgroundColor: '#FFFFFF', borderRadius: 16, - padding: 20, - marginBottom: 16, + padding: 16, }, iconPlaceholder: { width: 60, @@ -492,6 +620,8 @@ const styles = StyleSheet.create({ bottom: 0, padding: 16, backgroundColor: '#FFFFFF', + justifyContent: 'center', + alignItems: 'center', borderTopLeftRadius: 16, borderTopRightRadius: 16, }, diff --git a/components/DateSelector.tsx b/components/DateSelector.tsx new file mode 100644 index 0000000..f0f0ead --- /dev/null +++ b/components/DateSelector.tsx @@ -0,0 +1,222 @@ +import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; +import dayjs from 'dayjs'; +import React, { useEffect, useRef, useState } from 'react'; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +export interface DateSelectorProps { + /** 当前选中的日期索引 */ + selectedIndex?: number; + /** 日期选择回调 */ + onDateSelect?: (index: number, date: Date) => void; + /** 是否显示月份标题 */ + showMonthTitle?: boolean; + /** 自定义月份标题 */ + monthTitle?: string; + /** 是否禁用未来日期 */ + disableFutureDates?: boolean; + /** 自定义样式 */ + style?: any; + /** 容器样式 */ + containerStyle?: any; + /** 日期项样式 */ + dayItemStyle?: any; + /** 是否自动滚动到选中项 */ + autoScrollToSelected?: boolean; +} + +export const DateSelector: React.FC = ({ + selectedIndex: externalSelectedIndex, + onDateSelect, + showMonthTitle = true, + monthTitle: externalMonthTitle, + disableFutureDates = true, + style, + containerStyle, + dayItemStyle, + autoScrollToSelected = true, +}) => { + // 内部状态管理 + const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); + const selectedIndex = externalSelectedIndex ?? internalSelectedIndex; + + // 获取日期数据 + const days = getMonthDaysZh(); + const monthTitle = externalMonthTitle ?? 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 && autoScrollToSelected) { + scrollToIndex(selectedIndex, false); + } + }, [scrollWidth, selectedIndex, autoScrollToSelected]); + + // 当选中索引变化时,滚动到对应位置 + useEffect(() => { + if (scrollWidth > 0 && autoScrollToSelected) { + scrollToIndex(selectedIndex, true); + } + }, [selectedIndex, autoScrollToSelected]); + + // 处理日期选择 + const handleDateSelect = (index: number) => { + const targetDate = days[index]?.date?.toDate(); + if (!targetDate) return; + + // 检查是否为未来日期 + if (disableFutureDates && days[index].date.isAfter(dayjs(), 'day')) { + return; + } + + // 更新内部状态(如果使用外部控制则不更新) + if (externalSelectedIndex === undefined) { + setInternalSelectedIndex(index); + } + + // 调用回调 + onDateSelect?.(index, targetDate); + }; + + return ( + + {showMonthTitle && ( + {monthTitle} + )} + + setScrollWidth(e.nativeEvent.layout.width)} + style={style} + > + {days.map((d, i) => { + const selected = i === selectedIndex; + const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day'); + + return ( + + !isFutureDate && handleDateSelect(i)} + activeOpacity={isFutureDate ? 1 : 0.8} + disabled={isFutureDate} + > + + {d.weekdayZh} + + + {d.dayOfMonth} + + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + }, + monthTitle: { + fontSize: 24, + fontWeight: '800', + color: '#192126', + marginBottom: 14, + }, + daysContainer: { + paddingBottom: 8, + }, + dayItemWrapper: { + alignItems: 'center', + width: 48, + marginRight: 8, + }, + dayPill: { + width: 40, + height: 60, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + dayPillNormal: { + backgroundColor: 'transparent', + }, + dayPillSelected: { + backgroundColor: '#FFFFFF', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + dayPillDisabled: { + backgroundColor: 'transparent', + opacity: 0.4, + }, + dayLabel: { + fontSize: 11, + fontWeight: '700', + color: 'gray', + marginBottom: 2, + }, + dayLabelSelected: { + color: '#192126', + }, + dayLabelDisabled: { + color: 'gray', + }, + dayDate: { + fontSize: 12, + fontWeight: '800', + color: 'gray', + }, + dayDateSelected: { + color: '#192126', + }, + dayDateDisabled: { + color: 'gray', + }, +}); diff --git a/components/TaskCard.tsx b/components/TaskCard.tsx new file mode 100644 index 0000000..070cae7 --- /dev/null +++ b/components/TaskCard.tsx @@ -0,0 +1,260 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { TaskListItem } from '@/types/goals'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface TaskCardProps { + task: TaskListItem; + onPress?: (task: TaskListItem) => void; + onComplete?: (task: TaskListItem) => void; + onSkip?: (task: TaskListItem) => void; +} + +export const TaskCard: React.FC = ({ + task, + onPress, + onComplete, + onSkip, +}) => { + const theme = useColorScheme() ?? 'light'; + const colorTokens = Colors[theme]; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': + return '#10B981'; + case 'in_progress': + return '#F59E0B'; + case 'overdue': + return '#EF4444'; + case 'skipped': + return '#6B7280'; + default: + return '#3B82F6'; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'completed': + return '已完成'; + case 'in_progress': + return '进行中'; + case 'overdue': + return '已过期'; + case 'skipped': + return '已跳过'; + default: + return '待开始'; + } + }; + + const getCategoryColor = (category?: string) => { + if (!category) return '#6B7280'; + if (category.includes('运动') || category.includes('健身')) return '#EF4444'; + if (category.includes('工作')) return '#3B82F6'; + if (category.includes('健康')) return '#10B981'; + if (category.includes('财务')) return '#F59E0B'; + return '#6B7280'; + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (date.toDateString() === today.toDateString()) { + return '今天'; + } else if (date.toDateString() === tomorrow.toDateString()) { + return '明天'; + } else { + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); + } + }; + + return ( + onPress?.(task)} + activeOpacity={0.7} + > + + + + {task.title} + + {task.goal?.category && ( + + {task.goal?.category} + + )} + + + {getStatusText(task.status)} + + + + {task.description && ( + + {task.description} + + )} + + + + + 进度: {task.currentCount}/{task.targetCount} + + + {task.progressPercentage}% + + + + + + + + + + {formatDate(task.startDate)} + + + {task.status === 'pending' || task.status === 'in_progress' ? ( + + onSkip?.(task)} + > + 跳过 + + onComplete?.(task)} + > + 完成 + + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + padding: 16, + borderRadius: 12, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + titleContainer: { + flex: 1, + marginRight: 8, + }, + title: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + categoryTag: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + alignSelf: 'flex-start', + }, + categoryText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '500', + }, + statusTag: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '500', + }, + description: { + fontSize: 14, + marginBottom: 12, + lineHeight: 20, + }, + progressContainer: { + marginBottom: 12, + }, + progressInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + }, + progressText: { + fontSize: 12, + fontWeight: '500', + }, + progressBar: { + height: 6, + borderRadius: 3, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 3, + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + dateText: { + fontSize: 12, + fontWeight: '500', + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, + skipButton: { + backgroundColor: '#F3F4F6', + }, + skipButtonText: { + color: '#6B7280', + fontSize: 12, + fontWeight: '500', + }, + completeButton: { + backgroundColor: '#10B981', + }, + completeButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '500', + }, +}); diff --git a/components/TaskProgressCard.tsx b/components/TaskProgressCard.tsx new file mode 100644 index 0000000..7f81399 --- /dev/null +++ b/components/TaskProgressCard.tsx @@ -0,0 +1,140 @@ +import { TaskListItem } from '@/types/goals'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +interface TaskProgressCardProps { + tasks: TaskListItem[]; +} + +export const TaskProgressCard: React.FC = ({ + tasks, +}) => { + // 计算今日任务完成进度 + const todayTasks = tasks.filter(task => task.isToday); + const completedTodayTasks = todayTasks.filter(task => task.status === 'completed'); + const progressPercentage = todayTasks.length > 0 + ? Math.round((completedTodayTasks.length / todayTasks.length) * 100) + : 0; + + // 计算进度角度 + const progressAngle = (progressPercentage / 100) * 360; + + return ( + + {/* 左侧内容 */} + + + 今日目标 + 加油,快完成啦! + + + + + {/* 右侧进度圆环 */} + + {/* 背景圆环 */} + + + {/* 进度圆环 */} + + 0 ? '#8B5CF6' : 'transparent', + borderRightColor: progressAngle > 90 ? '#8B5CF6' : 'transparent', + borderBottomColor: progressAngle > 180 ? '#8B5CF6' : 'transparent', + borderLeftColor: progressAngle > 270 ? '#8B5CF6' : 'transparent', + transform: [{ rotate: '-90deg' }], + }, + ]} + /> + + + {/* 进度文字 */} + + {progressPercentage}% + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#8B5CF6', + borderRadius: 16, + padding: 20, + marginHorizontal: 20, + marginBottom: 20, + flexDirection: 'row', + alignItems: 'center', + position: 'relative', + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + leftContent: { + flex: 1, + marginRight: 20, + }, + textContainer: { + marginBottom: 16, + }, + title: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + marginBottom: 2, + }, + subtitle: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '400', + opacity: 0.9, + }, + progressContainer: { + width: 80, + height: 80, + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + }, + progressCircle: { + position: 'absolute', + width: 80, + height: 80, + borderRadius: 40, + }, + progressBackground: { + borderWidth: 6, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + progressFill: { + borderWidth: 6, + borderColor: 'transparent', + justifyContent: 'center', + alignItems: 'center', + }, + progressArc: { + position: 'absolute', + }, + progressTextContainer: { + position: 'absolute', + justifyContent: 'center', + alignItems: 'center', + }, + progressText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + }, +}); diff --git a/components/TimelineSchedule.tsx b/components/TimelineSchedule.tsx index c7a930f..c201734 100644 --- a/components/TimelineSchedule.tsx +++ b/components/TimelineSchedule.tsx @@ -111,9 +111,7 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin height, left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐 width: eventWidth - 8, // 增加卡片间距 - backgroundColor: event.isCompleted - ? `${categoryColor}40` - : `${categoryColor}80`, + backgroundColor: '#FFFFFF', // 白色背景 borderLeftColor: categoryColor, } ]} @@ -121,29 +119,57 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin activeOpacity={0.7} > - - {event.title} - - - {shouldShowTimeRange && ( - - {dayjs(event.startTime).format('HH:mm')} - {event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`} + {/* 顶部行:标题和分类标签 */} + + + {event.title} + + + {event.category === 'workout' ? '运动' : + event.category === 'finance' ? '财务' : + event.category === 'personal' ? '个人' : + event.category === 'work' ? '工作' : + event.category === 'health' ? '健康' : '其他'} + + + + + {/* 底部行:时间和图标 */} + {shouldShowTimeRange && ( + + + + + {dayjs(event.startTime).format('HH:mm A')} + + + + + {event.isCompleted ? ( + + ) : ( + + )} + + + )} - {event.isCompleted && ( + {/* 完成状态指示 */} + {event.isCompleted && !shouldShowTimeRange && ( - + )} @@ -301,32 +327,62 @@ const styles = StyleSheet.create({ }, eventContainer: { position: 'absolute', - borderRadius: 8, - borderLeftWidth: 4, + borderRadius: 12, + borderLeftWidth: 0, shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 4, }, eventContent: { flex: 1, - padding: 8, + padding: 12, justifyContent: 'space-between', - + }, + eventHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, }, eventTitle: { - fontSize: 12, + fontSize: 14, + fontWeight: '700', + lineHeight: 18, + flex: 1, + }, + categoryTag: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + categoryText: { + fontSize: 11, fontWeight: '600', - lineHeight: 16, + }, + eventFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + timeContainer: { + flexDirection: 'row', + alignItems: 'center', }, eventTime: { - fontSize: 10, + fontSize: 12, fontWeight: '500', - marginTop: 2, + marginLeft: 6, + color: '#8E8E93', + }, + iconContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, }, completedIcon: { position: 'absolute', diff --git a/components/statistic/HealthDataCard.tsx b/components/statistic/HealthDataCard.tsx new file mode 100644 index 0000000..d97aac4 --- /dev/null +++ b/components/statistic/HealthDataCard.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +interface HealthDataCardProps { + title: string; + value: string; + unit: string; + icon: React.ReactNode; + style?: object; +} + +const HealthDataCard: React.FC = ({ + title, + value, + unit, + icon, + style +}) => { + return ( + + + + {title} + + {value} + {unit} + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 16, + shadowColor: '#000', + paddingHorizontal: 16, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + marginVertical: 8, + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + marginRight: 16, + }, + content: { + flex: 1, + }, + title: { + fontSize: 14, + color: '#666', + marginBottom: 4, + }, + valueContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + value: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + }, + unit: { + fontSize: 14, + color: '#666', + marginLeft: 4, + marginBottom: 2, + }, +}); + +export default HealthDataCard; \ No newline at end of file diff --git a/components/statistic/HeartRateCard.tsx b/components/statistic/HeartRateCard.tsx new file mode 100644 index 0000000..424987f --- /dev/null +++ b/components/statistic/HeartRateCard.tsx @@ -0,0 +1,48 @@ +import { Ionicons } from '@expo/vector-icons'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet } from 'react-native'; +import HealthDataService from '../../services/healthData'; +import HealthDataCard from './HealthDataCard'; + +interface HeartRateCardProps { + resetToken: number; + style?: object; +} + +const HeartRateCard: React.FC = ({ + resetToken, + style +}) => { + const [heartRate, setHeartRate] = useState(null); + + useEffect(() => { + const fetchHeartRate = async () => { + const data = await HealthDataService.getHeartRate(); + setHeartRate(data); + }; + + fetchHeartRate(); + }, [resetToken]); + + const heartIcon = ( + + ); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +export default HeartRateCard; \ No newline at end of file diff --git a/components/statistic/OxygenSaturationCard.tsx b/components/statistic/OxygenSaturationCard.tsx new file mode 100644 index 0000000..f34e31b --- /dev/null +++ b/components/statistic/OxygenSaturationCard.tsx @@ -0,0 +1,48 @@ +import { Ionicons } from '@expo/vector-icons'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet } from 'react-native'; +import HealthDataService from '../../services/healthData'; +import HealthDataCard from './HealthDataCard'; + +interface OxygenSaturationCardProps { + resetToken: number; + style?: object; +} + +const OxygenSaturationCard: React.FC = ({ + resetToken, + style +}) => { + const [oxygenSaturation, setOxygenSaturation] = useState(null); + + useEffect(() => { + const fetchOxygenSaturation = async () => { + const data = await HealthDataService.getOxygenSaturation(); + setOxygenSaturation(data); + }; + + fetchOxygenSaturation(); + }, [resetToken]); + + const oxygenIcon = ( + + ); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +export default OxygenSaturationCard; \ No newline at end of file diff --git a/docs/tasks-implementation-summary.md b/docs/tasks-implementation-summary.md new file mode 100644 index 0000000..b89291d --- /dev/null +++ b/docs/tasks-implementation-summary.md @@ -0,0 +1,273 @@ +# 任务功能实现总结 + +## 概述 + +已成功将goals页面从显示目标列表改为显示任务列表,实现了完整的任务管理功能。任务系统基于目标自动生成,支持分步完成和进度追踪。同时保留了原有的目标管理功能,通过新的页面结构提供更好的用户体验。最新更新了任务统计区域和任务卡片设计,使用现代化的UI设计,完全按照设计稿实现高保真界面。 + +## 页面结构 + +### 1. 任务页面 (`app/(tabs)/goals.tsx`) +- **主要功能**: 显示任务列表,支持完成任务和跳过任务 +- **入口**: 底部导航栏的"目标"标签 +- **特色功能**: + - 任务进度卡片(高保真设计) + - 现代化任务卡片列表 + - 下拉刷新和上拉加载 + - 右上角"目标"按钮导航到目标管理页面 + +### 2. 目标管理页面 (`app/goals-detail.tsx`) +- **主要功能**: 显示原有的目标管理功能,包括滑动模块、日程表格等 +- **入口**: 任务页面右上角的"目标"按钮 +- **特色功能**: + - 今日待办事项卡片 + - 时间筛选选项卡(日/周/月) + - 日期选择器 + - 时间轴安排 + - 创建目标功能 + +## 已完成的功能 + +### 1. 数据结构定义 (`types/goals.ts`) +- ✅ 任务状态类型 (TaskStatus) +- ✅ 任务数据结构 (Task, TaskListItem) +- ✅ 任务查询参数 (GetTasksQuery) +- ✅ 完成任务请求数据 (CompleteTaskRequest) +- ✅ 跳过任务请求数据 (SkipTaskRequest) +- ✅ 任务统计信息 (TaskStats) + +### 2. API服务层 (`services/tasksApi.ts`) +- ✅ 获取任务列表 (GET /goals/tasks) +- ✅ 获取特定目标的任务列表 (GET /goals/:goalId/tasks) +- ✅ 完成任务 (POST /goals/tasks/:taskId/complete) +- ✅ 跳过任务 (POST /goals/tasks/:taskId/skip) +- ✅ 获取任务统计 (GET /goals/tasks/stats/overview) + +### 3. Redux状态管理 (`store/tasksSlice.ts`) +- ✅ 完整的异步操作 (createAsyncThunk) +- ✅ 任务列表状态管理 +- ✅ 任务统计状态管理 +- ✅ 完成任务和跳过任务操作 +- ✅ 乐观更新支持 +- ✅ 错误处理和加载状态 +- ✅ 分页数据管理 + +### 4. 任务卡片组件 (`components/TaskCard.tsx`) +- ✅ 现代化任务卡片UI设计,完全按照设计稿实现 +- ✅ 右上角分类图标(粉色、红色、绿色、黄色、紫色等) +- ✅ 项目/分类标签(小写灰色文字) +- ✅ 任务标题(粗体黑色文字) +- ✅ 时间显示(紫色时钟图标 + 时间 + 日期) +- ✅ 状态按钮(Done紫色、In Progress橙色、To-do蓝色等) +- ✅ 进度条显示(仅对多步骤任务显示) +- ✅ 阴影效果和圆角设计 +- ✅ 主题适配(明暗模式) + +### 5. 任务进度卡片组件 (`components/TaskProgressCard.tsx`) +- ✅ 高保真设计,完全按照设计稿实现 +- ✅ 紫色主题配色方案 +- ✅ 圆形进度条显示今日任务完成进度 +- ✅ 左侧文字区域:"今日目标" + "加油,快完成啦!" +- ✅ 阴影效果和圆角设计 +- ✅ 响应式进度计算 + +### 6. 页面集成 +- ✅ 任务页面 (`app/(tabs)/goals.tsx`) + - Redux状态集成 + - 任务进度卡片展示 + - 现代化任务卡片列表展示 + - 下拉刷新功能 + - 上拉加载更多 + - 完成任务功能 + - 跳过任务功能 + - 创建目标功能(保留原有功能) + - 错误提示和加载状态 + - 空状态处理 + - 目标管理页面导航 + +- ✅ 目标管理页面 (`app/goals-detail.tsx`) + - 保留原有的所有目标管理功能 + - 今日待办事项卡片 + - 时间筛选选项卡 + - 日期选择器 + - 时间轴安排 + - 创建目标功能 + - 返回导航功能 + +## 核心功能特性 + +### 任务状态管理 +- **pending**: 待开始 (To-do - 蓝色) +- **in_progress**: 进行中 (In Progress - 橙色) +- **completed**: 已完成 (Done - 紫色) +- **overdue**: 已过期 (Overdue - 红色) +- **skipped**: 已跳过 (Skipped - 灰色) + +### 进度追踪 +- 支持分步完成(如一天要喝8杯水,可以分8次上报) +- 自动计算完成进度百分比 +- 当完成次数达到目标次数时自动标记为完成 +- 实时更新任务进度卡片 + +### 数据流程 +1. **获取任务**: 页面加载 → 调用API → 更新Redux状态 → 渲染列表 +2. **完成任务**: 用户点击完成 → 调用API → 乐观更新 → 更新进度 +3. **跳过任务**: 用户点击跳过 → 调用API → 更新任务状态 +4. **创建目标**: 用户创建目标 → 系统自动生成任务 → 刷新任务列表 +5. **页面导航**: 任务页面 ↔ 目标管理页面 +6. **进度更新**: 任务状态变化 → 自动更新进度卡片 + +## API接口对应 + +| 功能 | API接口 | 实现状态 | +|------|---------|----------| +| 获取任务列表 | GET /goals/tasks | ✅ | +| 获取特定目标任务 | GET /goals/:goalId/tasks | ✅ | +| 完成任务 | POST /goals/tasks/:taskId/complete | ✅ | +| 跳过任务 | POST /goals/tasks/:taskId/skip | ✅ | +| 获取任务统计 | GET /goals/tasks/stats/overview | ✅ | + +## 使用方式 + +### 查看任务 +1. 进入goals页面,自动显示任务进度卡片和任务列表 +2. 查看今日任务完成进度(圆形进度条显示) +3. 下拉刷新获取最新任务 +4. 上拉加载更多历史任务 + +### 完成任务 +1. 在任务卡片中点击"完成"按钮 +2. 系统自动记录完成次数 +3. 更新任务进度和状态 +4. 进度卡片实时更新完成百分比 + +### 跳过任务 +1. 在任务卡片中点击"跳过"按钮 +2. 系统记录跳过原因 +3. 更新任务状态为已跳过 + +### 创建目标 +1. 点击页面右上角的"+"按钮 +2. 填写目标信息 +3. 系统自动生成相应的任务 + +### 访问目标管理 +1. 点击页面右上角的"目标"按钮 +2. 进入目标管理页面 +3. 查看原有的滑动模块、日程表格等功能 + +### 任务进度卡片交互 +1. 进度条实时显示今日任务完成情况 +2. 简洁的设计,专注于进度展示 + +## 技术特点 + +1. **类型安全**: 完整的TypeScript类型定义 +2. **状态管理**: Redux Toolkit + RTK Query模式 +3. **乐观更新**: 提升用户体验 +4. **错误处理**: 完整的错误提示和恢复机制 +5. **响应式设计**: 适配不同屏幕尺寸 +6. **主题适配**: 支持明暗模式切换 +7. **性能优化**: 分页加载,避免一次性加载过多数据 +8. **页面导航**: 使用expo-router实现页面间导航 +9. **高保真UI**: 完全按照设计稿实现,包括颜色、字体、阴影等细节 +10. **现代化设计**: 采用最新的UI设计趋势和最佳实践 + +## 页面布局 + +### 任务页面布局 +#### 顶部区域 +- 页面标题:"任务" +- 目标管理按钮(带图标) +- 创建目标按钮(+) + +#### 任务进度卡片区域 +- 紫色背景卡片 +- 左侧文字:"今日目标" + "加油,快完成啦!" +- 右侧圆形进度条(显示完成百分比) + +#### 任务列表区域 +- 现代化任务卡片列表 +- 下拉刷新 +- 上拉加载更多 +- 空状态提示 + +### 目标管理页面布局 +#### 顶部区域 +- 返回按钮 +- 页面标题:"目标管理" +- 创建目标按钮(+) + +#### 内容区域 +- 今日待办事项卡片 +- 时间筛选选项卡 +- 日期选择器(周/月模式) +- 时间轴安排 + +## 设计亮点 + +### 任务卡片设计 +- **现代化布局**: 右上角图标 + 主要内容区域 + 右下角状态按钮 +- **分类图标**: 根据任务分类显示不同颜色的图标(粉色、红色、绿色、黄色、紫色) +- **时间显示**: 紫色时钟图标 + 时间 + 日期,信息层次清晰 +- **状态按钮**: 不同状态使用不同颜色(Done紫色、In Progress橙色、To-do蓝色等) +- **进度条**: 仅对多步骤任务显示,避免界面冗余 +- **阴影效果**: 轻微的阴影增强立体感 +- **圆角设计**: 统一的圆角半径,保持设计一致性 + +### 任务进度卡片设计 +- **配色方案**: 使用紫色主题(#8B5CF6),符合现代设计趋势 +- **圆形进度条**: 使用border和transform实现,性能优秀 +- **文字层次**: 主标题和副标题的字体大小和权重区分 +- **阴影效果**: 添加适当的阴影,增强立体感 +- **圆角设计**: 统一的圆角半径,保持设计一致性 + +### 交互体验 +- **实时更新**: 任务状态变化时进度卡片立即更新 +- **视觉反馈**: 按钮点击有透明度变化 +- **流畅动画**: 进度条变化平滑自然 +- **信息层次**: 清晰的信息架构,重要信息突出显示 + +## 后续优化建议 + +1. **任务筛选**: 添加按状态、日期范围筛选功能 +2. **任务搜索**: 支持按标题搜索任务 +3. **任务详情**: 添加任务详情页面 +4. **批量操作**: 支持批量完成任务 +5. **推送通知**: 集成推送服务,实现任务提醒 +6. **数据同步**: 实现多设备数据同步 +7. **统计分析**: 添加更详细的任务完成统计和分析 +8. **离线支持**: 支持离线完成任务,网络恢复后同步 +9. **页面动画**: 添加页面切换动画效果 +10. **手势操作**: 支持滑动完成任务等手势操作 +11. **进度动画**: 为进度条添加平滑的动画效果 +12. **主题切换**: 支持多种颜色主题选择 +13. **卡片动画**: 为任务卡片添加进入和退出动画 +14. **拖拽排序**: 支持拖拽重新排序任务 + +## 测试建议 + +1. **单元测试**: 测试Redux reducers和API服务 +2. **集成测试**: 测试完整的数据流程 +3. **UI测试**: 测试组件交互和状态变化 +4. **端到端测试**: 测试完整的用户流程 +5. **性能测试**: 测试大量任务时的性能表现 +6. **导航测试**: 测试页面间导航功能 +7. **进度计算测试**: 测试进度条计算的准确性 +8. **响应式测试**: 测试不同屏幕尺寸下的显示效果 +9. **主题测试**: 测试明暗模式切换效果 +10. **可访问性测试**: 测试色盲用户的可访问性 + +## 注意事项 + +1. **API兼容性**: 确保后端API接口与前端调用一致 +2. **错误处理**: 网络异常时的用户提示和恢复机制 +3. **数据一致性**: 乐观更新失败时的回滚机制 +4. **用户体验**: 加载状态和空状态的友好提示 +5. **性能考虑**: 大量任务时的分页和虚拟化处理 +6. **导航体验**: 确保页面间导航流畅自然 +7. **状态保持**: 页面切换时保持用户操作状态 +8. **设计一致性**: 确保所有UI组件遵循统一的设计规范 +9. **进度准确性**: 确保进度计算逻辑正确,避免显示错误 +10. **可访问性**: 考虑色盲用户的可访问性需求 +11. **国际化**: 考虑多语言支持的需求 +12. **性能优化**: 大量任务时的渲染性能优化 diff --git a/package-lock.json b/package-lock.json index 1a1446e..a3b6910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "react-native-toast-message": "^2.3.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", + "react-native-wheel-picker-expo": "^0.5.4", "react-redux": "^9.2.0" }, "devDependencies": { @@ -11700,6 +11701,18 @@ "react-native": "*" } }, + "node_modules/react-native-wheel-picker-expo": { + "version": "0.5.4", + "resolved": "https://mirrors.tencent.com/npm/react-native-wheel-picker-expo/-/react-native-wheel-picker-expo-0.5.4.tgz", + "integrity": "sha512-mTA35pqAGioi7gie+nLF4EcwCj7zU36zzlYZVRS/bZul84zvvoMFKJu6Sm84WuWQiUJ40J1EgYxQ3Ui1pIMJZg==", + "license": "MIT", + "peerDependencies": { + "expo-haptics": ">=11", + "expo-linear-gradient": ">=11", + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.79.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz", diff --git a/package.json b/package.json index e27b037..ba1d7ce 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react-native-toast-message": "^2.3.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", + "react-native-wheel-picker-expo": "^0.5.4", "react-redux": "^9.2.0" }, "devDependencies": { diff --git a/services/healthData.ts b/services/healthData.ts new file mode 100644 index 0000000..ff41ee9 --- /dev/null +++ b/services/healthData.ts @@ -0,0 +1,114 @@ +import { Platform } from 'react-native'; +import { + HKQuantityTypeIdentifier, + HKQuantitySample, + getMostRecentQuantitySample, + isAvailable, + authorize, +} from 'react-native-health'; + +interface HealthData { + oxygenSaturation: number | null; + heartRate: number | null; + lastUpdated: Date | null; +} + +class HealthDataService { + private static instance: HealthDataService; + private isAuthorized = false; + + private constructor() {} + + public static getInstance(): HealthDataService { + if (!HealthDataService.instance) { + HealthDataService.instance = new HealthDataService(); + } + return HealthDataService.instance; + } + + async requestAuthorization(): Promise { + if (Platform.OS !== 'ios') { + return false; + } + + try { + const available = await isAvailable(); + if (!available) { + return false; + } + + const permissions = [ + { + type: HKQuantityTypeIdentifier.OxygenSaturation, + access: 'read' as const + }, + { + type: HKQuantityTypeIdentifier.HeartRate, + access: 'read' as const + } + ]; + + const authorized = await authorize(permissions); + this.isAuthorized = authorized; + return authorized; + } catch (error) { + console.error('Health data authorization error:', error); + return false; + } + } + + async getOxygenSaturation(): Promise { + if (!this.isAuthorized) { + return null; + } + + try { + const sample: HKQuantitySample | null = await getMostRecentQuantitySample( + HKQuantityTypeIdentifier.OxygenSaturation + ); + + if (sample) { + return Number(sample.value.toFixed(1)); + } + return null; + } catch (error) { + console.error('Error reading oxygen saturation:', error); + return null; + } + } + + async getHeartRate(): Promise { + if (!this.isAuthorized) { + return null; + } + + try { + const sample: HKQuantitySample | null = await getMostRecentQuantitySample( + HKQuantityTypeIdentifier.HeartRate + ); + + if (sample) { + return Math.round(sample.value); + } + return null; + } catch (error) { + console.error('Error reading heart rate:', error); + return null; + } + } + + async getHealthData(): Promise { + const [oxygenSaturation, heartRate] = await Promise.all([ + this.getOxygenSaturation(), + this.getHeartRate() + ]); + + return { + oxygenSaturation, + heartRate, + lastUpdated: new Date() + }; + } +} + +export default HealthDataService.getInstance(); \ No newline at end of file diff --git a/services/tasksApi.ts b/services/tasksApi.ts new file mode 100644 index 0000000..5895681 --- /dev/null +++ b/services/tasksApi.ts @@ -0,0 +1,83 @@ +import { + ApiResponse, + CompleteTaskRequest, + GetTasksQuery, + PaginatedResponse, + SkipTaskRequest, + Task, + TaskListItem, + TaskStats, +} from '@/types/goals'; +import { api } from './api'; + +// 任务管理API服务 + +/** + * 获取任务列表 + */ +export const getTasks = async (query: GetTasksQuery = {}): Promise> => { + const searchParams = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + + const queryString = searchParams.toString(); + const path = queryString ? `/goals/tasks?${queryString}` : '/goals/tasks'; + + return api.get>(path); +}; + +/** + * 获取特定目标的任务列表 + */ +export const getTasksByGoalId = async (goalId: string, query: GetTasksQuery = {}): Promise> => { + const searchParams = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + + const queryString = searchParams.toString(); + const path = queryString ? `/goals/${goalId}/tasks?${queryString}` : `/goals/${goalId}/tasks`; + + return api.get>(path); +}; + +/** + * 完成任务 + */ +export const completeTask = async (taskId: string, completionData: CompleteTaskRequest = {}): Promise => { + return api.post(`/goals/tasks/${taskId}/complete`, completionData); +}; + +/** + * 跳过任务 + */ +export const skipTask = async (taskId: string, skipData: SkipTaskRequest = {}): Promise => { + return api.post(`/goals/tasks/${taskId}/skip`, skipData); +}; + +/** + * 获取任务统计 + */ +export const getTaskStats = async (goalId?: string): Promise => { + const path = goalId ? `/goals/tasks/stats/overview?goalId=${goalId}` : '/goals/tasks/stats/overview'; + const response = await api.get>(path); + return response.data; +}; + +// 导出所有API方法 +export const tasksApi = { + getTasks, + getTasksByGoalId, + completeTask, + skipTask, + getTaskStats, +}; + +export default tasksApi; diff --git a/store/index.ts b/store/index.ts index 7e9e711..06a52e0 100644 --- a/store/index.ts +++ b/store/index.ts @@ -5,6 +5,7 @@ import exerciseLibraryReducer from './exerciseLibrarySlice'; import goalsReducer from './goalsSlice'; import moodReducer from './moodSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice'; +import tasksReducer from './tasksSlice'; import trainingPlanReducer from './trainingPlanSlice'; import userReducer from './userSlice'; import workoutReducer from './workoutSlice'; @@ -44,6 +45,7 @@ export const store = configureStore({ checkin: checkinReducer, goals: goalsReducer, mood: moodReducer, + tasks: tasksReducer, trainingPlan: trainingPlanReducer, scheduleExercise: scheduleExerciseReducer, exerciseLibrary: exerciseLibraryReducer, diff --git a/store/tasksSlice.ts b/store/tasksSlice.ts new file mode 100644 index 0000000..77fa557 --- /dev/null +++ b/store/tasksSlice.ts @@ -0,0 +1,318 @@ +import { tasksApi } from '@/services/tasksApi'; +import { + CompleteTaskRequest, + GetTasksQuery, + SkipTaskRequest, + TaskListItem, + TaskStats, +} from '@/types/goals'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +// 任务管理状态类型 +export interface TasksState { + // 任务列表 + tasks: TaskListItem[]; + tasksLoading: boolean; + tasksError: string | null; + tasksPagination: { + page: number; + pageSize: number; + total: number; + hasMore: boolean; + }; + + // 任务统计 + stats: TaskStats | null; + statsLoading: boolean; + statsError: string | null; + + // 完成任务 + completeLoading: boolean; + completeError: string | null; + + // 跳过任务 + skipLoading: boolean; + skipError: string | null; + + // 筛选和搜索 + filters: GetTasksQuery; +} + +const initialState: TasksState = { + tasks: [], + tasksLoading: false, + tasksError: null, + tasksPagination: { + page: 1, + pageSize: 20, + total: 0, + hasMore: false, + }, + + stats: null, + statsLoading: false, + statsError: null, + + completeLoading: false, + completeError: null, + + skipLoading: false, + skipError: null, + + filters: { + page: 1, + pageSize: 20, + }, +}; + +// 异步操作 + +/** + * 获取任务列表 + */ +export const fetchTasks = createAsyncThunk( + 'tasks/fetchTasks', + async (query: GetTasksQuery = {}, { rejectWithValue }) => { + try { + const response = await tasksApi.getTasks(query); + console.log('fetchTasks response', response); + return { query, response }; + } catch (error: any) { + return rejectWithValue(error.message || '获取任务列表失败'); + } + } +); + +/** + * 加载更多任务 + */ +export const loadMoreTasks = createAsyncThunk( + 'tasks/loadMoreTasks', + async (_, { getState, rejectWithValue }) => { + try { + const state = getState() as { tasks: TasksState }; + const { filters, tasksPagination } = state.tasks; + + if (!tasksPagination.hasMore) { + return { tasks: [], pagination: tasksPagination }; + } + + const query = { + ...filters, + page: tasksPagination.page + 1, + }; + + const response = await tasksApi.getTasks(query); + console.log('loadMoreTasks response', response); + + return { query, response }; + } catch (error: any) { + return rejectWithValue(error.message || '加载更多任务失败'); + } + } +); + +/** + * 完成任务 + */ +export const completeTask = createAsyncThunk( + 'tasks/completeTask', + async ({ taskId, completionData }: { taskId: string; completionData?: CompleteTaskRequest }, { rejectWithValue }) => { + try { + const response = await tasksApi.completeTask(taskId, completionData); + console.log('completeTask response', response); + return response; + } catch (error: any) { + return rejectWithValue(error.message || '完成任务失败'); + } + } +); + +/** + * 跳过任务 + */ +export const skipTask = createAsyncThunk( + 'tasks/skipTask', + async ({ taskId, skipData }: { taskId: string; skipData?: SkipTaskRequest }, { rejectWithValue }) => { + try { + const response = await tasksApi.skipTask(taskId, skipData); + console.log('skipTask response', response); + return response; + } catch (error: any) { + return rejectWithValue(error.message || '跳过任务失败'); + } + } +); + +/** + * 获取任务统计 + */ +export const fetchTaskStats = createAsyncThunk( + 'tasks/fetchTaskStats', + async (goalId: string, { rejectWithValue }) => { + try { + const response = await tasksApi.getTaskStats(goalId); + console.log('fetchTaskStats response', response); + return response; + } catch (error: any) { + return rejectWithValue(error.message || '获取任务统计失败'); + } + } +); + +// Redux Slice +const tasksSlice = createSlice({ + name: 'tasks', + initialState, + reducers: { + // 清除错误 + clearErrors: (state) => { + state.tasksError = null; + state.completeError = null; + state.skipError = null; + state.statsError = null; + }, + + // 更新筛选条件 + updateFilters: (state, action: PayloadAction>) => { + state.filters = { ...state.filters, ...action.payload }; + }, + + // 重置筛选条件 + resetFilters: (state) => { + state.filters = { + page: 1, + pageSize: 20, + }; + }, + + // 乐观更新任务完成状态 + optimisticCompleteTask: (state, action: PayloadAction<{ taskId: string; count?: number }>) => { + const { taskId, count = 1 } = action.payload; + const task = state.tasks.find(t => t.id === taskId); + if (task) { + const newCount = Math.min(task.currentCount + count, task.targetCount); + task.currentCount = newCount; + task.progressPercentage = Math.round((newCount / task.targetCount) * 100); + + if (newCount >= task.targetCount) { + task.status = 'completed'; + task.completedAt = new Date().toISOString(); + } else if (newCount > 0) { + task.status = 'in_progress'; + } + } + }, + }, + extraReducers: (builder) => { + builder + // 获取任务列表 + .addCase(fetchTasks.pending, (state) => { + state.tasksLoading = true; + state.tasksError = null; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.tasksLoading = false; + const { query, response } = action.payload; + + // 如果是第一页,替换数据;否则追加数据 + state.tasks = response.list; + state.tasksPagination = { + page: response.page, + pageSize: response.pageSize, + total: response.total, + hasMore: response.page * response.pageSize < response.total, + }; + }) + .addCase(fetchTasks.rejected, (state, action) => { + state.tasksLoading = false; + state.tasksError = action.payload as string; + }) + + // 加载更多任务 + .addCase(loadMoreTasks.pending, (state) => { + state.tasksLoading = true; + }) + .addCase(loadMoreTasks.fulfilled, (state, action) => { + state.tasksLoading = false; + const { response } = action.payload; + + if (!response) { + return; + } + + state.tasks = [...state.tasks, ...response.list]; + state.tasksPagination = { + page: response.page, + pageSize: response.pageSize, + total: response.total, + hasMore: response.page * response.pageSize < response.total, + }; + }) + .addCase(loadMoreTasks.rejected, (state, action) => { + state.tasksLoading = false; + state.tasksError = action.payload as string; + }) + + // 完成任务 + .addCase(completeTask.pending, (state) => { + state.completeLoading = true; + state.completeError = null; + }) + .addCase(completeTask.fulfilled, (state, action) => { + state.completeLoading = false; + // 更新任务列表中的对应任务 + const updatedTask = action.payload; + const index = state.tasks.findIndex(t => t.id === updatedTask.id); + if (index !== -1) { + state.tasks[index] = updatedTask; + } + }) + .addCase(completeTask.rejected, (state, action) => { + state.completeLoading = false; + state.completeError = action.payload as string; + }) + + // 跳过任务 + .addCase(skipTask.pending, (state) => { + state.skipLoading = true; + state.skipError = null; + }) + .addCase(skipTask.fulfilled, (state, action) => { + state.skipLoading = false; + // 更新任务列表中的对应任务 + const updatedTask = action.payload; + const index = state.tasks.findIndex(t => t.id === updatedTask.id); + if (index !== -1) { + state.tasks[index] = updatedTask; + } + }) + .addCase(skipTask.rejected, (state, action) => { + state.skipLoading = false; + state.skipError = action.payload as string; + }) + + // 获取任务统计 + .addCase(fetchTaskStats.pending, (state) => { + state.statsLoading = true; + state.statsError = null; + }) + .addCase(fetchTaskStats.fulfilled, (state, action) => { + state.statsLoading = false; + state.stats = action.payload; + }) + .addCase(fetchTaskStats.rejected, (state, action) => { + state.statsLoading = false; + state.statsError = action.payload as string; + }); + }, +}); + +export const { + clearErrors, + updateFilters, + resetFilters, + optimisticCompleteTask, +} = tasksSlice.actions; + +export default tasksSlice.reducer; diff --git a/types/goals.ts b/types/goals.ts index 2d18e20..3fbcb23 100644 --- a/types/goals.ts +++ b/types/goals.ts @@ -177,4 +177,76 @@ export interface GoalDetailResponse extends Goal { export interface GoalListItem extends Goal { progressPercentage: number; daysRemaining?: number; +} + +// 任务相关类型定义 + +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'overdue' | 'skipped'; + +// 任务数据结构 +export interface Task { + id: string; + goalId: string; + userId: string; + title: string; + description?: string; + startDate: string; // ISO date string + endDate: string; // ISO date string + targetCount: number; + currentCount: number; + status: TaskStatus; + progressPercentage: number; + completedAt?: string; // ISO datetime string + notes?: string; + metadata?: Record; + daysRemaining: number; + isToday: boolean; + goal?: { + id: string; + title: string; + repeatType: RepeatType; + frequency: number; + category?: string; + }; +} + +// 获取任务列表的查询参数 +export interface GetTasksQuery { + goalId?: string; + status?: TaskStatus; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +} + +// 完成任务的请求数据 +export interface CompleteTaskRequest { + count?: number; + notes?: string; + completedAt?: string; +} + +// 跳过任务的请求数据 +export interface SkipTaskRequest { + reason?: string; +} + +// 任务统计信息 +export interface TaskStats { + total: number; + pending: number; + inProgress: number; + completed: number; + overdue: number; + skipped: number; + totalProgress: number; + todayTasks: number; + weekTasks: number; + monthTasks: number; +} + +// 任务列表项响应 +export interface TaskListItem extends Task { + // 继承Task的所有属性 } \ No newline at end of file