feat: 完善目标管理功能及相关组件

- 新增创建目标弹窗,支持用户输入目标信息并提交
- 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件
- 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示
- 更新时间轴组件,支持动态显示目标安排
- 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 12:05:27 +08:00
parent 136c800084
commit 231620d778
11 changed files with 1811 additions and 169 deletions

View File

@@ -0,0 +1,523 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
import DateTimePicker from '@react-native-community/datetimepicker';
import React, { useState } from 'react';
import {
Alert,
Modal,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
interface CreateGoalModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (goalData: CreateGoalRequest) => void;
loading?: boolean;
}
const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
{ value: 'daily', label: '每日' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义' },
];
const FREQUENCY_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
visible,
onClose,
onSubmit,
loading = false,
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 表单状态
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [repeatType, setRepeatType] = useState<RepeatType>('daily');
const [frequency, setFrequency] = useState(1);
const [hasReminder, setHasReminder] = useState(false);
const [reminderTime, setReminderTime] = useState('20:00');
const [category, setCategory] = useState('');
const [priority, setPriority] = useState<GoalPriority>(5);
const [showTimePicker, setShowTimePicker] = useState(false);
const [tempSelectedTime, setTempSelectedTime] = useState<Date | null>(null);
// 重置表单
const resetForm = () => {
setTitle('');
setDescription('');
setRepeatType('daily');
setFrequency(1);
setHasReminder(false);
setReminderTime('19:00');
setCategory('');
setPriority(5);
};
// 处理关闭
const handleClose = () => {
if (!loading) {
resetForm();
onClose();
}
};
// 处理提交
const handleSubmit = () => {
if (!title.trim()) {
Alert.alert('提示', '请输入目标标题');
return;
}
// 计算startTime从reminderTime中获取小时和分钟转换为当天的分钟数
let startTime: number | undefined;
if (reminderTime) {
const [hours, minutes] = reminderTime.split(':').map(Number);
startTime = hours * 60 + minutes;
}
const goalData: CreateGoalRequest = {
title: title.trim(),
description: description.trim() || undefined,
repeatType,
frequency,
category: category.trim() || undefined,
priority,
hasReminder,
reminderTime: hasReminder ? reminderTime : undefined,
reminderSettings: hasReminder ? {
enabled: true,
weekdays: [1, 2, 3, 4, 5, 6, 0], // 默认每天
} : undefined,
startTime,
};
console.log('goalData', goalData);
onSubmit(goalData);
};
// 时间选择器
const handleTimeChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === 'android') {
// Android: 用户点击系统确认按钮后自动关闭
if (event.type === 'set' && selectedDate) {
const hours = selectedDate.getHours().toString().padStart(2, '0');
const minutes = selectedDate.getMinutes().toString().padStart(2, '0');
setReminderTime(`${hours}:${minutes}`);
}
setShowTimePicker(false);
} else {
// iOS: 只在用户点击自定义确认按钮时更新
if (selectedDate) {
setTempSelectedTime(selectedDate);
}
}
};
const handleConfirmTime = () => {
setShowTimePicker(false);
if (tempSelectedTime) {
const hours = tempSelectedTime.getHours().toString().padStart(2, '0');
const minutes = tempSelectedTime.getMinutes().toString().padStart(2, '0');
setReminderTime(`${hours}:${minutes}`);
}
setTempSelectedTime(null);
};
const handleCancelTime = () => {
setShowTimePicker(false);
setTempSelectedTime(null);
};
const showTimePickerModal = () => {
setShowTimePicker(true);
};
// 获取当前时间对应的Date对象
const getCurrentTimeDate = () => {
const [hours, minutes] = reminderTime.split(':').map(Number);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return date;
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleClose}
>
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={handleClose} disabled={loading}>
<Text style={[styles.cancelButton, { color: colorTokens.text }]}>
</Text>
</TouchableOpacity>
<Text style={[styles.title, { color: colorTokens.text }]}>
</Text>
<View style={{ width: 24 }} />
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 目标标题输入 */}
<View style={styles.section}>
<View style={styles.iconTitleContainer}>
<View style={styles.iconPlaceholder}>
<Text style={styles.iconText}></Text>
</View>
<TextInput
style={[styles.titleInput, { color: colorTokens.text }]}
placeholder="写点什么..."
placeholderTextColor={colorTokens.textSecondary}
value={title}
onChangeText={setTitle}
multiline
maxLength={100}
/>
</View>
{/* 装饰图案 */}
<View style={styles.decorationContainer}>
<View style={styles.decoration} />
</View>
</View>
{/* 目标重复周期 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.optionHeader}>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>🔄</Text>
</View>
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity style={styles.optionValue}>
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
</Text>
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
</Text>
</TouchableOpacity>
</View>
</View>
{/* 频率设置 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.optionHeader}>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>📊</Text>
</View>
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity style={styles.optionValue}>
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
{frequency}
</Text>
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
</Text>
</TouchableOpacity>
</View>
</View>
{/* 提醒设置 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.optionHeader}>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>🔔</Text>
</View>
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text>
<Switch
value={hasReminder}
onValueChange={setHasReminder}
trackColor={{ false: '#E5E5E5', true: '#6366F1' }}
thumbColor={hasReminder ? '#FFFFFF' : '#FFFFFF'}
/>
</View>
</View>
{/* 时间设置 */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<View style={styles.optionHeader}>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}></Text>
</View>
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity
style={[styles.optionValue]}
onPress={showTimePickerModal}
>
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
{reminderTime}
</Text>
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
</Text>
</TouchableOpacity>
</View>
</View>
{/* 时间选择器弹窗 */}
<Modal
visible={showTimePicker}
transparent
animationType="fade"
onRequestClose={() => setShowTimePicker(false)}
>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={handleCancelTime}
/>
<View style={styles.modalSheet}>
<DateTimePicker
value={tempSelectedTime || getCurrentTimeDate()}
mode="time"
is24Hour={true}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={handleTimeChange}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtn]}
onPress={handleCancelTime}
>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtn, styles.modalBtnPrimary]}
onPress={handleConfirmTime}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</View>
</Modal>
{/* 描述输入(可选) */}
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
<TextInput
style={[styles.descriptionInput, { color: colorTokens.text }]}
placeholder="添加描述(可选)"
placeholderTextColor={colorTokens.textSecondary}
value={description}
onChangeText={setDescription}
multiline
maxLength={500}
/>
</View>
</ScrollView>
{/* 保存按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={[
styles.saveButton,
{ opacity: loading || !title.trim() ? 0.5 : 1 }
]}
onPress={handleSubmit}
disabled={loading || !title.trim()}
>
<Text style={styles.saveButtonText}>
{loading ? '保存中...' : '保存'}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
},
cancelButton: {
fontSize: 24,
fontWeight: '600',
},
title: {
fontSize: 18,
fontWeight: '600',
},
content: {
flex: 1,
paddingHorizontal: 20,
},
section: {
marginBottom: 24,
},
iconTitleContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 16,
},
iconPlaceholder: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
iconText: {
fontSize: 12,
color: '#9CA3AF',
fontWeight: '500',
},
titleInput: {
flex: 1,
fontSize: 16,
fontWeight: '500',
minHeight: 60,
textAlignVertical: 'top',
},
decorationContainer: {
alignItems: 'flex-end',
paddingRight: 20,
},
decoration: {
width: 80,
height: 60,
backgroundColor: '#E0E7FF',
borderRadius: 40,
opacity: 0.6,
},
optionCard: {
borderRadius: 16,
marginBottom: 12,
overflow: 'hidden',
},
optionHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
optionIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
optionIconText: {
fontSize: 16,
},
optionLabel: {
flex: 1,
fontSize: 16,
fontWeight: '500',
},
optionValue: {
flexDirection: 'row',
alignItems: 'center',
},
optionValueText: {
fontSize: 16,
fontWeight: '500',
marginRight: 8,
},
chevron: {
fontSize: 20,
fontWeight: '300',
},
descriptionInput: {
padding: 16,
fontSize: 16,
minHeight: 80,
textAlignVertical: 'top',
},
footer: {
padding: 20,
paddingBottom: 40,
},
saveButton: {
backgroundColor: '#6366F1',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#F1F5F9',
},
modalBtnPrimary: {
backgroundColor: '#6366F1',
},
modalBtnText: {
color: '#334155',
fontWeight: '700',
},
modalBtnTextPrimary: {
color: '#FFFFFF',
fontWeight: '700',
},
});
export default CreateGoalModal;

View File

@@ -73,7 +73,7 @@ const styles = StyleSheet.create({
},
tabContainer: {
flexDirection: 'row',
borderRadius: 12,
borderRadius: 20,
padding: 4,
shadowColor: '#000',
shadowOffset: {
@@ -87,7 +87,7 @@ const styles = StyleSheet.create({
tab: {
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 16,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},

View File

@@ -44,7 +44,8 @@ const getEventStyle = (event: TimelineEvent) => {
const endMinutes = endTime.hour() * 60 + endTime.minute();
const durationMinutes = endMinutes - startMinutes;
const top = (startMinutes / 60) * HOUR_HEIGHT;
// 计算top位置时需要加上时间标签的paddingTop偏移(8px)
const top = (startMinutes / 60) * HOUR_HEIGHT + 8;
const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 30); // 最小高度30
return { top, height };
@@ -93,9 +94,13 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
const categoryColor = getCategoryColor(event.category);
// 计算水平偏移和宽度,用于处理重叠事件
const eventWidth = (screenWidth - TIME_LABEL_WIDTH - 40) / Math.max(groupSize, 1);
const availableWidth = screenWidth - TIME_LABEL_WIDTH - 48; // 减少一些边距
const eventWidth = availableWidth / Math.max(groupSize, 1);
const leftOffset = index * eventWidth;
// 判断是否应该显示时间段 - 当卡片高度小于50或宽度小于80时隐藏时间段
const shouldShowTimeRange = height >= 50 && eventWidth >= 80;
return (
<TouchableOpacity
key={event.id}
@@ -104,8 +109,8 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
{
top,
height,
left: TIME_LABEL_WIDTH + 20 + leftOffset,
width: eventWidth - 4,
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
width: eventWidth - 8, // 增加卡片间距
backgroundColor: event.isCompleted
? `${categoryColor}40`
: `${categoryColor}80`,
@@ -124,15 +129,17 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
}
]}
numberOfLines={2}
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时标题只显示1行
>
{event.title}
</Text>
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
{dayjs(event.startTime).format('HH:mm')}
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
</Text>
{shouldShowTimeRange && (
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
{dayjs(event.startTime).format('HH:mm')}
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
</Text>
)}
{event.isCompleted && (
<View style={styles.completedIcon}>
@@ -146,22 +153,23 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
return (
<View style={styles.container}>
{/* 日期标题 */}
<View style={styles.dateHeader}>
<Text style={[styles.dateText, { color: colorTokens.text }]}>
{dayjs(selectedDate).format('YYYY年M月D日 dddd')}
</Text>
<Text style={[styles.eventCount, { color: colorTokens.textSecondary }]}>
{events.length}
</Text>
</View>
{/* 时间轴 */}
<ScrollView
style={styles.timelineContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 50 }}
>
{/* 日期标题 */}
<View style={styles.dateHeader}>
<Text style={[styles.dateText, { color: colorTokens.text }]}>
{dayjs(selectedDate).format('YYYY年M月D日 dddd')}
</Text>
<Text style={[styles.eventCount, { color: colorTokens.textSecondary }]}>
{events.length}
</Text>
</View>
<View style={styles.timeline}>
{/* 时间标签 */}
<View style={styles.timeLabelsContainer}>
@@ -220,7 +228,8 @@ function CurrentTimeLine() {
const now = dayjs();
const currentMinutes = now.hour() * 60 + now.minute();
const top = (currentMinutes / 60) * HOUR_HEIGHT;
// 当前时间线也需要加上时间标签的paddingTop偏移(8px)
const top = (currentMinutes / 60) * HOUR_HEIGHT + 8;
return (
<View
@@ -245,16 +254,14 @@ const styles = StyleSheet.create({
dateHeader: {
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
dateText: {
fontSize: 18,
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
eventCount: {
fontSize: 14,
fontSize: 12,
fontWeight: '500',
},
timelineContainer: {
@@ -283,7 +290,7 @@ const styles = StyleSheet.create({
flex: 1,
borderBottomWidth: 1,
marginLeft: 8,
marginRight: 20,
marginRight: 24,
},
eventsContainer: {
position: 'absolute',
@@ -309,6 +316,7 @@ const styles = StyleSheet.create({
flex: 1,
padding: 8,
justifyContent: 'space-between',
},
eventTitle: {
fontSize: 12,
@@ -327,8 +335,8 @@ const styles = StyleSheet.create({
},
currentTimeLine: {
position: 'absolute',
left: TIME_LABEL_WIDTH + 20,
right: 20,
left: TIME_LABEL_WIDTH + 24,
right: 24,
height: 2,
zIndex: 10,
},

View File

@@ -95,9 +95,7 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
<Text style={styles.categoryText}>{item.category}</Text>
</View>
{item.priority && (
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
)}
<Ionicons name="star-outline" size={18} color={colorTokens.textMuted} />
</View>
{/* 主要内容 */}
@@ -117,11 +115,11 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
<View style={styles.footer}>
<View style={styles.timeContainer}>
<Ionicons
name={isToday ? "time" : "calendar-outline"}
name='time-outline'
size={14}
color={colorTokens.textMuted}
/>
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
<Text style={[styles.timeText]}>
{timeFormatted}
</Text>
</View>
@@ -159,13 +157,6 @@ const styles = StyleSheet.create({
marginHorizontal: 8,
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 6,
position: 'relative',
},

View File

@@ -52,7 +52,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
</ScrollView>
{/* 底部指示器 */}
<View style={styles.indicatorContainer}>
{/* <View style={styles.indicatorContainer}>
{todos.map((_, index) => (
<View
key={index}
@@ -62,7 +62,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
]}
/>
))}
</View>
</View> */}
</View>
);
}
@@ -71,7 +71,6 @@ const styles = StyleSheet.create({
container: {
},
scrollView: {
marginBottom: 12,
},
scrollContent: {
paddingHorizontal: 20,