feat: 新增任务管理功能及相关组件

- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能
- 新增任务卡片和任务进度卡片组件,展示任务状态和进度
- 实现任务数据的获取和管理,集成Redux状态管理
- 更新API服务,支持任务相关的CRUD操作
- 编写任务管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 17:30:14 +08:00
parent 231620d778
commit 259f10540e
21 changed files with 2756 additions and 608 deletions

View File

@@ -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<import('react-native').ScrollView | null>(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<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(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<string | null>(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 (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={backgroundGradientColors}
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={styles.scrollView}
@@ -351,46 +324,13 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}></Text>
<WeightHistoryCard />
{/* 标题与日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = d.date.isAfter(dayjs(), 'day');
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && onSelectDate(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}> {d.weekdayZh} </Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={styles.selectedDot} />}
</View>
);
})}
</ScrollView>
{/* 日期选择 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
@@ -491,6 +431,22 @@ export default function ExploreScreen() {
/>
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard} delay={1750}>
<OxygenSaturationCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 心率卡片 */}
<FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View>
</View>
</ScrollView>
@@ -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',