Files
carry-your-live/carry-your-live/src/components/DateTimePicker.tsx
T
2026-08-09 21:42:54 +02:00

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,
},
});