307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import React, { useRef } from 'react';
|
|
import { View, Text, StyleSheet, TouchableOpacity, Switch } from 'react-native';
|
|
import { Controller, Control, useWatch } from 'react-hook-form';
|
|
import { TaskFormData } from '@/types';
|
|
import { useSettings } from '@/theme';
|
|
import DateTimeInput from '@/components/DateTimeInput';
|
|
import { WheelTimePicker } from '@/components/WheelTimePicker';
|
|
import Svg, { Path, Circle } from 'react-native-svg';
|
|
import { format } from 'date-fns';
|
|
|
|
interface DateTimePickerComponentProps {
|
|
control: Control<TaskFormData>;
|
|
}
|
|
|
|
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
|
|
const { theme } = useSettings();
|
|
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
|
|
const dateRef = useRef<((value: Date | null) => void) | null>(null);
|
|
const timeRef = useRef<((value: string) => void) | null>(null);
|
|
const endTimeRef = useRef<((value: string) => void) | null>(null);
|
|
|
|
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
|
|
const dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
|
|
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
|
|
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
|
|
|
|
const endTimeInvalid = startTime !== '' && endTime !== '' && endTime <= startTime;
|
|
|
|
const setDateValue = (value: Date | null) => dateRef.current?.(value);
|
|
const setTimeValue = (value: string) => timeRef.current?.(value);
|
|
const setEndTimeValue = (value: string) => endTimeRef.current?.(value);
|
|
|
|
const handlePickerDismiss = () => setPicker(null);
|
|
|
|
const renderTimeButton = (
|
|
fieldName: 'dueTime' | 'endTime',
|
|
ref: React.MutableRefObject<((value: string) => void) | null>,
|
|
placeholder: string,
|
|
onPress: () => void
|
|
) => (
|
|
<Controller
|
|
control={control}
|
|
name={fieldName}
|
|
render={({ field }) => {
|
|
ref.current = field.onChange;
|
|
return (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.pickerButton,
|
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
|
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
|
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft },
|
|
]}
|
|
onPress={onPress}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
|
|
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
|
</Svg>
|
|
<Text
|
|
style={[
|
|
styles.pickerText,
|
|
{ color: field.value ? theme.text : theme.textMuted },
|
|
field.value && styles.pickerTextFilled,
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{field.value ? formatTime12(field.value) : placeholder}
|
|
</Text>
|
|
{field.value ? (
|
|
<TouchableOpacity
|
|
style={styles.clearButton}
|
|
onPress={() => {
|
|
if (fieldName === 'dueTime') {
|
|
field.onChange('');
|
|
endTimeRef.current?.('');
|
|
} else {
|
|
field.onChange('');
|
|
}
|
|
}}
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
>
|
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
|
</Svg>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
</TouchableOpacity>
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Text style={[styles.label, { color: theme.text }]}>Date & Time</Text>
|
|
<View style={styles.dateRow}>
|
|
<Controller
|
|
control={control}
|
|
name="dueDate"
|
|
render={({ field }) => {
|
|
dateRef.current = field.onChange;
|
|
return (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.pickerButton,
|
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
|
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
|
]}
|
|
onPress={() => setPicker('date')}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
<Path
|
|
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
|
stroke={field.value ? theme.accent : theme.textFaint}
|
|
strokeWidth={1.8}
|
|
strokeLinecap="round"
|
|
/>
|
|
<Path
|
|
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
|
|
stroke={field.value ? theme.accent : theme.textFaint}
|
|
strokeWidth={1.8}
|
|
fill="none"
|
|
/>
|
|
</Svg>
|
|
<Text
|
|
style={[
|
|
styles.pickerText,
|
|
{ color: field.value ? theme.text : theme.textMuted },
|
|
field.value && styles.pickerTextFilled,
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
|
|
</Text>
|
|
{field.value ? (
|
|
<TouchableOpacity
|
|
style={styles.clearButton}
|
|
onPress={() => setDateValue(null)}
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
>
|
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
|
</Svg>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
</TouchableOpacity>
|
|
);
|
|
}}
|
|
/>
|
|
</View>
|
|
<Controller
|
|
control={control}
|
|
name="allDay"
|
|
render={({ field }) => (
|
|
<View style={styles.allDayRow}>
|
|
<Text style={[styles.allDayLabel, { color: theme.text }]}>All Day</Text>
|
|
<Switch
|
|
trackColor={{ false: theme.border, true: theme.accent }}
|
|
thumbColor={field.value ? theme.accent : theme.textMuted}
|
|
ios_backgroundColor={theme.border}
|
|
value={field.value ?? false}
|
|
onValueChange={(value) => {
|
|
field.onChange(value);
|
|
if (value) {
|
|
timeRef.current?.('');
|
|
endTimeRef.current?.('');
|
|
}
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
{!allDay && (
|
|
<>
|
|
<View style={styles.timeRow}>
|
|
{renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
|
|
{renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
|
|
</View>
|
|
|
|
{endTimeInvalid && (
|
|
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={1.8} fill="none" />
|
|
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
|
|
</Svg>
|
|
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
|
{endTime === startTime
|
|
? 'The end time is the same as the start time.'
|
|
: 'The end time is before the start time.'}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<DateTimeInput
|
|
testID="date-picker"
|
|
value={dueDate ?? new Date()}
|
|
mode="date"
|
|
is24Hour={false}
|
|
isVisible={picker === 'date'}
|
|
onChange={(_event, selectedDate) => {
|
|
if (selectedDate) {
|
|
setDateValue(selectedDate);
|
|
}
|
|
}}
|
|
onDismiss={handlePickerDismiss}
|
|
/>
|
|
|
|
<WheelTimePicker
|
|
visible={picker === 'time' || picker === 'endTime'}
|
|
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
|
initialTime={
|
|
picker === 'endTime'
|
|
? endTime || startTime
|
|
: startTime
|
|
}
|
|
onConfirm={(time) => {
|
|
if (picker === 'endTime') {
|
|
setEndTimeValue(time);
|
|
} else {
|
|
setTimeValue(time);
|
|
}
|
|
handlePickerDismiss();
|
|
}}
|
|
onDismiss={handlePickerDismiss}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function formatTime12(time: string): string {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const h = hours % 12 === 0 ? 12 : hours % 12;
|
|
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
gap: 8,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
dateRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
allDayRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 14,
|
|
},
|
|
allDayLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '500',
|
|
},
|
|
timeRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
pickerButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
minHeight: 52,
|
|
},
|
|
pickerText: {
|
|
fontSize: 15,
|
|
flex: 1,
|
|
flexShrink: 1,
|
|
},
|
|
pickerTextFilled: {
|
|
fontWeight: '500',
|
|
},
|
|
clearButton: {
|
|
padding: 4,
|
|
},
|
|
warningRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
},
|
|
warningText: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
flex: 1,
|
|
},
|
|
}); |