- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知 - 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则 - 集成目标通知测试组件,方便开发者测试不同类型的通知 - 更新相关文档,详细描述目标通知功能的实现和使用方法 - 优化目标页面,确保用户体验和界面一致性
450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
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<TimeTabType>('day');
|
|
const [selectedDate, setSelectedDate] = useState<Date>(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<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]);
|
|
|
|
// 日期选择处理
|
|
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 (
|
|
<SafeAreaView style={styles.container}>
|
|
<StatusBar
|
|
backgroundColor="transparent"
|
|
translucent
|
|
/>
|
|
|
|
{/* 背景渐变 */}
|
|
<LinearGradient
|
|
colors={['#F0F9FF', '#E0F2FE']}
|
|
style={styles.gradientBackground}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
|
|
{/* 装饰性圆圈 */}
|
|
<View style={styles.decorativeCircle1} />
|
|
<View style={styles.decorativeCircle2} />
|
|
|
|
<View style={styles.content}>
|
|
{/* 标题区域 */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={handleBackPress}
|
|
>
|
|
<Text style={styles.backButtonText}>←</Text>
|
|
</TouchableOpacity>
|
|
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
|
目标管理
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.addButton}
|
|
onPress={() => setShowCreateModal(true)}
|
|
>
|
|
<Text style={styles.addButtonText}>+</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* 今日目标卡片 */}
|
|
<GoalCarousel
|
|
goals={todayGoals}
|
|
onGoalPress={handleGoalPress}
|
|
/>
|
|
|
|
{/* 时间筛选选项卡 */}
|
|
<TimeTabSelector
|
|
selectedTab={selectedTab}
|
|
onTabChange={handleTabChange}
|
|
/>
|
|
|
|
{/* 日期选择器 - 在周和月模式下显示 */}
|
|
{(selectedTab === 'week' || selectedTab === 'month') && (
|
|
<View style={styles.dateSelector}>
|
|
<DateSelector
|
|
selectedIndex={selectedIndex}
|
|
onDateSelect={(index, date) => onSelectDate(index)}
|
|
showMonthTitle={true}
|
|
disableFutureDates={true}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* 时间轴安排 */}
|
|
<View style={styles.timelineSection}>
|
|
<TimelineSchedule
|
|
events={filteredTimelineEvents}
|
|
selectedDate={selectedDate}
|
|
onEventPress={handleEventPress}
|
|
/>
|
|
</View>
|
|
|
|
{/* 创建目标弹窗 */}
|
|
<CreateGoalModal
|
|
visible={showCreateModal}
|
|
onClose={() => setShowCreateModal(false)}
|
|
onSubmit={handleCreateGoal}
|
|
loading={createLoading}
|
|
/>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|