Files
digital-pilates/app/(tabs)/goals.tsx
richarjiang 7d28b79d86 feat: 集成推送通知功能及相关组件
- 在项目中引入expo-notifications库,支持本地推送通知功能
- 实现通知权限管理,用户可选择开启或关闭通知
- 新增通知发送、定时通知和重复通知功能
- 更新个人页面,集成通知开关和权限请求逻辑
- 编写推送通知功能实现文档,详细描述功能和使用方法
- 优化心情日历页面,确保数据实时刷新
2025-08-22 22:00:05 +08:00

485 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import CreateGoalModal from '@/components/CreateGoalModal';
import { TaskCard } from '@/components/TaskCard';
import { TaskFilterTabs, TaskFilterType } from '@/components/TaskFilterTabs';
import { TaskProgressCard } from '@/components/TaskProgressCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, createGoal } from '@/store/goalsSlice';
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, FlatList, Image, 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 {
tasks,
tasksLoading,
tasksError,
tasksPagination,
completeLoading,
completeError,
skipLoading,
skipError,
} = useAppSelector((state) => state.tasks);
const {
createLoading,
createError
} = useAppSelector((state) => state.goals);
const [showCreateModal, setShowCreateModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
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('tasksError', tasksError);
console.log('createError', createError);
console.log('completeError', completeError);
console.log('skipError', skipError);
if (tasksError) {
Alert.alert('错误', tasksError);
dispatch(clearTaskErrors());
}
if (createError) {
Alert.alert('创建失败', createError);
dispatch(clearErrors());
}
if (completeError) {
Alert.alert('完成失败', completeError);
dispatch(clearTaskErrors());
}
if (skipError) {
Alert.alert('跳过失败', skipError);
dispatch(clearTaskErrors());
}
}, [tasksError, createError, completeError, skipError, dispatch]);
// 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
try {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
Alert.alert('成功', '目标创建成功!');
// 创建目标后重新加载任务列表
loadTasks();
} catch (error) {
// 错误已在useEffect中处理
}
};
// 导航到目标管理页面
const handleNavigateToGoals = () => {
router.push('/goals-detail');
};
// 计算各状态的任务数量
const taskCounts = {
all: tasks.length,
pending: tasks.filter(task => task.status === 'pending').length,
completed: tasks.filter(task => task.status === 'completed').length,
};
// 根据筛选条件过滤任务
const filteredTasks = React.useMemo(() => {
switch (selectedFilter) {
case 'pending':
return tasks.filter(task => task.status === 'pending');
case 'completed':
return tasks.filter(task => task.status === 'completed');
default:
return tasks;
}
}, [tasks, selectedFilter]);
// 处理筛选变化
const handleFilterChange = (filter: TaskFilterType) => {
setSelectedFilter(filter);
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
/>
);
// 渲染空状态
const renderEmptyState = () => {
let title = '暂无任务';
let subtitle = '创建目标后,系统会自动生成相应的任务';
if (selectedFilter === 'pending') {
title = '暂无待完成的任务';
subtitle = '当前没有待完成的任务';
} else if (selectedFilter === 'completed') {
title = '暂无已完成的任务';
subtitle = '完成一些任务后,它们会显示在这里';
}
return (
<View style={styles.emptyState}>
<Image
source={require('@/assets/images/task/ImageEmpty.png')}
style={styles.emptyStateImage}
resizeMode="contain"
/>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
{title}
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
{subtitle}
</Text>
</View>
);
};
// 渲染加载更多
const renderLoadMore = () => {
if (!tasksPagination.hasMore) return null;
return (
<View style={styles.loadMoreContainer}>
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
{tasksLoading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
);
};
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={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: '#7A5AF8',
height: 233,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
}}>
{/* 右下角图片 */}
<Image
source={require('@/assets/images/task/imageTodo.png')}
style={styles.bottomRightImage}
resizeMode="contain"
/>
</View>
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.header}>
<View>
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
</Text>
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
</Text>
</View>
</View>
{/* 任务进度卡片 */}
<View >
<TaskProgressCard
tasks={tasks}
headerButtons={
<View style={styles.cardHeaderButtons}>
<TouchableOpacity
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
onPress={handleNavigateToGoals}
>
<MaterialIcons name="flag" size={16} color={colorTokens.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
onPress={() => setShowCreateModal(true)}
>
<Text style={styles.cardAddButtonText}>+</Text>
</TouchableOpacity>
</View>
}
/>
</View>
{/* 任务筛选标签 */}
<TaskFilterTabs
selectedFilter={selectedFilter}
onFilterChange={handleFilterChange}
taskCounts={taskCounts}
/>
{/* 任务列表 */}
<View style={styles.taskListContainer}>
<FlatList
data={filteredTasks}
renderItem={renderTaskItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.taskList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0EA5E9']}
tintColor="#0EA5E9"
/>
}
onEndReached={handleLoadMoreTasks}
onEndReachedThreshold={0.1}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderLoadMore}
/>
</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,
},
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,
},
pageTitle2: {
fontSize: 16,
fontWeight: '400',
color: '#FFFFFF',
lineHeight: 24,
},
addButton: {
width: 30,
height: 30,
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: 22,
fontWeight: '600',
lineHeight: 22,
},
taskListContainer: {
flex: 1,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
taskList: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateImage: {
width: 223,
height: 59,
marginBottom: 20,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
loadMoreContainer: {
alignItems: 'center',
paddingVertical: 20,
},
loadMoreText: {
fontSize: 14,
fontWeight: '500',
},
bottomRightImage: {
position: 'absolute',
top: 56,
right: 36,
width: 80,
height: 80,
},
// 任务进度卡片中的按钮样式
cardHeaderButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
cardGoalsButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#F3F4F6',
borderWidth: 1,
},
cardAddButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
cardAddButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
lineHeight: 18,
},
});