641 lines
20 KiB
TypeScript
641 lines
20 KiB
TypeScript
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
Modal,
|
|
StyleSheet,
|
|
Dimensions,
|
|
ScrollView,
|
|
PanResponder,
|
|
} from 'react-native';
|
|
import Ionicons from '@react-native-vector-icons/ionicons';
|
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
|
|
|
import { useTheme, fontFamily } from '../../theme';
|
|
|
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
const DAY_SIZE = Math.floor((SCREEN_WIDTH - 80) / 7);
|
|
|
|
const MONTHS = [
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December',
|
|
];
|
|
const MONTHS_SHORT = [
|
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
];
|
|
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
|
|
// Generate year range: 1950 → current year + 10
|
|
const CURRENT_YEAR = new Date().getFullYear();
|
|
const YEAR_RANGE_START = 1950;
|
|
const YEAR_RANGE_END = CURRENT_YEAR + 10;
|
|
const YEARS = Array.from({ length: YEAR_RANGE_END - YEAR_RANGE_START + 1 }, (_, i) => YEAR_RANGE_START + i);
|
|
|
|
interface DatePickerProps {
|
|
label?: string;
|
|
placeholder?: string;
|
|
value?: string; // DD/MM/YYYY
|
|
onDateChange: (dateStr: string) => void;
|
|
error?: string;
|
|
/** Max selectable date. Defaults to today */
|
|
maxDate?: Date;
|
|
/** Min selectable date */
|
|
minDate?: Date;
|
|
}
|
|
|
|
type PickerMode = 'days' | 'months' | 'years';
|
|
|
|
const parseDDMMYYYY = (str: string | undefined): Date | null => {
|
|
if (!str) return null;
|
|
const parts = str.split('/');
|
|
if (parts.length !== 3) return null;
|
|
const part0 = parts[0];
|
|
const part1 = parts[1];
|
|
const part2 = parts[2];
|
|
if (!part0 || !part1 || !part2) return null;
|
|
const d = parseInt(part0, 10);
|
|
const m = parseInt(part1, 10) - 1;
|
|
const y = parseInt(part2, 10);
|
|
if (isNaN(d) || isNaN(m) || isNaN(y)) return null;
|
|
return new Date(y, m, d);
|
|
};
|
|
|
|
const formatDDMMYYYY = (date: Date): string => {
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const y = date.getFullYear();
|
|
return `${d}/${m}/${y}`;
|
|
};
|
|
|
|
const isSameDay = (a: Date, b: Date) =>
|
|
a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
|
|
|
export const DatePicker = ({
|
|
label,
|
|
placeholder = 'DD/MM/YYYY',
|
|
value,
|
|
onDateChange,
|
|
error,
|
|
maxDate,
|
|
minDate,
|
|
}: DatePickerProps) => {
|
|
const theme = useTheme();
|
|
const today = useMemo(() => new Date(), []);
|
|
|
|
const selectedDate = useMemo(() => parseDDMMYYYY(value), [value]);
|
|
const [visible, setVisible] = useState(false);
|
|
const [viewMonth, setViewMonth] = useState(selectedDate?.getMonth() ?? today.getMonth());
|
|
const [viewYear, setViewYear] = useState(selectedDate?.getFullYear() ?? today.getFullYear());
|
|
const [pickerMode, setPickerMode] = useState<PickerMode>('days');
|
|
|
|
const yearScrollRef = useRef<ScrollView>(null);
|
|
|
|
const openCalendar = useCallback(() => {
|
|
const ref = selectedDate ?? today;
|
|
setViewMonth(ref.getMonth());
|
|
setViewYear(ref.getFullYear());
|
|
setPickerMode('days');
|
|
setVisible(true);
|
|
}, [selectedDate, today]);
|
|
|
|
// ── Month navigation ──────────────────────────────────────
|
|
const prevMonth = useCallback(() => {
|
|
if (viewMonth === 0) {
|
|
setViewMonth(11);
|
|
setViewYear(y => y - 1);
|
|
} else {
|
|
setViewMonth(m => m - 1);
|
|
}
|
|
}, [viewMonth]);
|
|
|
|
const nextMonth = useCallback(() => {
|
|
if (viewMonth === 11) {
|
|
setViewMonth(0);
|
|
setViewYear(y => y + 1);
|
|
} else {
|
|
setViewMonth(m => m + 1);
|
|
}
|
|
}, [viewMonth]);
|
|
|
|
// ── Swipe handler (PanResponder) ──────────────────────────
|
|
const swipeThreshold = 50;
|
|
const panResponder = useRef(
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => false,
|
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
|
// Only capture horizontal swipes
|
|
return Math.abs(gestureState.dx) > 15 && Math.abs(gestureState.dy) < 30;
|
|
},
|
|
onPanResponderRelease: (_, gestureState) => {
|
|
if (gestureState.dx > swipeThreshold) {
|
|
// Swipe right → previous month (functional updates avoid stale closure)
|
|
setViewMonth(m => {
|
|
if (m === 0) {
|
|
setViewYear(y => y - 1);
|
|
return 11;
|
|
}
|
|
return m - 1;
|
|
});
|
|
} else if (gestureState.dx < -swipeThreshold) {
|
|
// Swipe left → next month
|
|
setViewMonth(m => {
|
|
if (m === 11) {
|
|
setViewYear(y => y + 1);
|
|
return 0;
|
|
}
|
|
return m + 1;
|
|
});
|
|
}
|
|
},
|
|
})
|
|
).current;
|
|
|
|
// ── Day calculations ──────────────────────────────────────
|
|
const daysInMonth = useMemo(() => new Date(viewYear, viewMonth + 1, 0).getDate(), [viewYear, viewMonth]);
|
|
const firstDayOfWeek = useMemo(() => new Date(viewYear, viewMonth, 1).getDay(), [viewYear, viewMonth]);
|
|
|
|
const calendarDays = useMemo(() => {
|
|
const days: (number | null)[] = [];
|
|
for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
|
|
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
|
return days;
|
|
}, [daysInMonth, firstDayOfWeek]);
|
|
|
|
const isDayDisabled = useCallback(
|
|
(day: number) => {
|
|
const d = new Date(viewYear, viewMonth, day);
|
|
if (maxDate && d > maxDate) return true;
|
|
if (minDate && d < minDate) return true;
|
|
return false;
|
|
},
|
|
[viewYear, viewMonth, maxDate, minDate],
|
|
);
|
|
|
|
const handleSelect = (day: number) => {
|
|
const d = new Date(viewYear, viewMonth, day);
|
|
onDateChange(formatDDMMYYYY(d));
|
|
setVisible(false);
|
|
};
|
|
|
|
// ── Month picker handler ──────────────────────────────────
|
|
const handleMonthSelect = (monthIndex: number) => {
|
|
setViewMonth(monthIndex);
|
|
setPickerMode('days');
|
|
};
|
|
|
|
// ── Year picker handler ───────────────────────────────────
|
|
const handleYearSelect = (year: number) => {
|
|
setViewYear(year);
|
|
setPickerMode('days');
|
|
};
|
|
|
|
// Auto-scroll to current year when year picker opens
|
|
const handleOpenYearPicker = useCallback(() => {
|
|
setPickerMode('years');
|
|
// Scroll to the current viewYear position after render
|
|
setTimeout(() => {
|
|
const yearIndex = YEARS.indexOf(viewYear);
|
|
if (yearIndex >= 0 && yearScrollRef.current) {
|
|
yearScrollRef.current.scrollTo({ y: Math.max(0, (yearIndex - 2) * 48), animated: false });
|
|
}
|
|
}, 100);
|
|
}, [viewYear]);
|
|
|
|
// ── Render: Days grid ─────────────────────────────────────
|
|
const renderDaysView = () => (
|
|
<>
|
|
{/* Header: tappable month & year */}
|
|
<View style={styles.calendarHeader}>
|
|
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
|
<Ionicons name="chevron-back" size={20} color={theme.colors.primary} />
|
|
</Pressable>
|
|
|
|
<View style={styles.monthYearWrap}>
|
|
<Pressable onPress={() => setPickerMode('months')} style={styles.headerTappable}>
|
|
<Text style={[styles.monthText, { color: theme.colors.primary }]}>{MONTHS[viewMonth]}</Text>
|
|
<Ionicons name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
|
|
</Pressable>
|
|
<Pressable onPress={handleOpenYearPicker} style={styles.headerTappable}>
|
|
<Text style={[styles.yearText, { color: theme.colors.textMuted }]}>{viewYear}</Text>
|
|
<Ionicons name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
|
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.primary} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Weekday headers */}
|
|
<View style={styles.weekdayRow}>
|
|
{WEEKDAYS.map(wd => (
|
|
<View key={wd} style={[styles.dayCell, { width: DAY_SIZE, height: 32 }]}>
|
|
<Text style={[styles.weekdayText, { color: theme.colors.textMuted }]}>{wd}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Days grid with swipe */}
|
|
<View {...panResponder.panHandlers}>
|
|
<View style={styles.daysGrid}>
|
|
{calendarDays.map((day, idx) => {
|
|
if (day === null) {
|
|
return <View key={`empty-${idx}`} style={[styles.dayCell, { width: DAY_SIZE, height: DAY_SIZE }]} />;
|
|
}
|
|
|
|
const dateObj = new Date(viewYear, viewMonth, day);
|
|
const isToday = isSameDay(dateObj, today);
|
|
const isSelected = selectedDate ? isSameDay(dateObj, selectedDate) : false;
|
|
const disabled = isDayDisabled(day);
|
|
|
|
return (
|
|
<Pressable
|
|
key={day}
|
|
disabled={disabled}
|
|
onPress={() => handleSelect(day)}
|
|
style={[
|
|
styles.dayCell,
|
|
{ width: DAY_SIZE, height: DAY_SIZE },
|
|
isSelected && {
|
|
backgroundColor: theme.colors.primary,
|
|
borderRadius: DAY_SIZE / 2,
|
|
},
|
|
isToday && !isSelected && {
|
|
borderWidth: 1.5,
|
|
borderColor: theme.colors.primary,
|
|
borderRadius: DAY_SIZE / 2,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.dayText,
|
|
{ color: theme.colors.textPrimary },
|
|
isSelected && { color: theme.colors.white, fontFamily: fontFamily.bold },
|
|
isToday && !isSelected && { color: theme.colors.primary, fontFamily: fontFamily.bold },
|
|
disabled && { color: theme.colors.textMuted, opacity: 0.4 },
|
|
]}
|
|
>
|
|
{day}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
<View style={[styles.calendarFooter, { borderTopColor: theme.colors.cardBorder }]}>
|
|
<Pressable onPress={() => setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}>
|
|
<Text style={[styles.footerBtnText, { color: theme.colors.textMuted }]}>Cancel</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => {
|
|
setViewMonth(today.getMonth());
|
|
setViewYear(today.getFullYear());
|
|
handleSelect(today.getDate());
|
|
}}
|
|
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
|
|
>
|
|
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontFamily: fontFamily.bold }]}>Today</Text>
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
// ── Render: Month picker grid ─────────────────────────────
|
|
const renderMonthsView = () => (
|
|
<>
|
|
<View style={styles.pickerHeader}>
|
|
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
|
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
|
</Pressable>
|
|
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Month</Text>
|
|
<View style={{ width: 22 }} />
|
|
</View>
|
|
|
|
<View style={styles.monthGrid}>
|
|
{MONTHS_SHORT.map((m, idx) => {
|
|
const isActive = idx === viewMonth;
|
|
return (
|
|
<Pressable
|
|
key={m}
|
|
onPress={() => handleMonthSelect(idx)}
|
|
style={[
|
|
styles.monthCell,
|
|
{
|
|
backgroundColor: isActive ? theme.colors.primary : `${theme.colors.primary}08`,
|
|
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.monthCellText,
|
|
{ color: isActive ? '#fff' : theme.colors.textPrimary },
|
|
]}
|
|
>
|
|
{m}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
// ── Render: Year picker list ──────────────────────────────
|
|
const renderYearsView = () => (
|
|
<>
|
|
<View style={styles.pickerHeader}>
|
|
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
|
|
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
|
|
</Pressable>
|
|
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Year</Text>
|
|
<View style={{ width: 22 }} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
ref={yearScrollRef}
|
|
style={styles.yearList}
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={styles.yearListContent}
|
|
>
|
|
{YEARS.map(year => {
|
|
const isActive = year === viewYear;
|
|
return (
|
|
<Pressable
|
|
key={year}
|
|
onPress={() => handleYearSelect(year)}
|
|
style={[
|
|
styles.yearRow,
|
|
{
|
|
backgroundColor: isActive ? `${theme.colors.primary}12` : 'transparent',
|
|
borderColor: isActive ? theme.colors.primary : 'transparent',
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.yearRowText,
|
|
{
|
|
color: isActive ? theme.colors.primary : theme.colors.textPrimary,
|
|
fontFamily: isActive ? fontFamily.bold : fontFamily.medium,
|
|
},
|
|
]}
|
|
>
|
|
{year}
|
|
</Text>
|
|
{isActive && (
|
|
<Ionicons name="checkmark" size={20} color={theme.colors.primary} />
|
|
)}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{label && (
|
|
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
|
{label.toUpperCase()}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Tappable field */}
|
|
<Pressable
|
|
onPress={openCalendar}
|
|
style={[
|
|
styles.inputWrapper,
|
|
{
|
|
borderColor: error ? theme.colors.error : theme.colors.cardBorder,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
|
|
<Text
|
|
style={[
|
|
styles.inputText,
|
|
{ color: value ? theme.colors.textPrimary : theme.colors.textMuted },
|
|
]}
|
|
>
|
|
{value || placeholder}
|
|
</Text>
|
|
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
|
|
</Pressable>
|
|
|
|
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
|
|
|
|
{/* Calendar Modal */}
|
|
<Modal transparent visible={visible} animationType="fade" onRequestClose={() => setVisible(false)}>
|
|
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setVisible(false)}>
|
|
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
|
|
<Pressable
|
|
style={[
|
|
styles.calendarCard,
|
|
{
|
|
backgroundColor: theme.colors.backgroundElevated,
|
|
borderColor: theme.colors.cardBorder,
|
|
},
|
|
]}
|
|
onPress={() => {}} // prevent dismiss when tapping inside
|
|
>
|
|
{pickerMode === 'days' && renderDaysView()}
|
|
{pickerMode === 'months' && renderMonthsView()}
|
|
{pickerMode === 'years' && renderYearsView()}
|
|
</Pressable>
|
|
</Animated.View>
|
|
</Pressable>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
gap: 6,
|
|
},
|
|
label: {
|
|
fontSize: 11,
|
|
fontFamily: fontFamily.bold,
|
|
letterSpacing: 0.5,
|
|
marginLeft: 4,
|
|
},
|
|
inputWrapper: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
height: 52,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 16,
|
|
},
|
|
inputText: {
|
|
flex: 1,
|
|
fontSize: 15,
|
|
fontFamily: fontFamily.medium,
|
|
},
|
|
errorText: {
|
|
fontSize: 12,
|
|
fontFamily: fontFamily.medium,
|
|
marginTop: 2,
|
|
marginLeft: 4,
|
|
},
|
|
overlay: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 20,
|
|
},
|
|
calendarCard: {
|
|
width: SCREEN_WIDTH - 40,
|
|
borderRadius: 20,
|
|
borderWidth: 1,
|
|
padding: 20,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 10 },
|
|
shadowOpacity: 0.15,
|
|
shadowRadius: 20,
|
|
elevation: 10,
|
|
},
|
|
|
|
// ── Header ───────────────────────────────────
|
|
calendarHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
navBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
monthYearWrap: {
|
|
alignItems: 'center',
|
|
},
|
|
headerTappable: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 3,
|
|
},
|
|
monthText: {
|
|
fontSize: 18,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
yearText: {
|
|
fontSize: 13,
|
|
fontFamily: fontFamily.semibold,
|
|
marginTop: 2,
|
|
},
|
|
|
|
// ── Days grid ────────────────────────────────
|
|
weekdayRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-around',
|
|
marginBottom: 4,
|
|
},
|
|
weekdayText: {
|
|
fontSize: 12,
|
|
fontFamily: fontFamily.bold,
|
|
letterSpacing: 0.5,
|
|
},
|
|
daysGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
justifyContent: 'flex-start',
|
|
},
|
|
dayCell: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
dayText: {
|
|
fontSize: 14,
|
|
fontFamily: fontFamily.medium,
|
|
},
|
|
|
|
// ── Footer ───────────────────────────────────
|
|
calendarFooter: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: 12,
|
|
marginTop: 16,
|
|
paddingTop: 12,
|
|
borderTopWidth: 1,
|
|
},
|
|
footerBtn: {
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: 'transparent',
|
|
},
|
|
todayBtn: {
|
|
borderWidth: 0,
|
|
},
|
|
footerBtnText: {
|
|
fontSize: 14,
|
|
fontFamily: fontFamily.semibold,
|
|
},
|
|
|
|
// ── Month / Year picker shared ───────────────
|
|
pickerHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 20,
|
|
},
|
|
pickerTitle: {
|
|
fontSize: 17,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
|
|
// ── Month picker grid ────────────────────────
|
|
monthGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 10,
|
|
justifyContent: 'center',
|
|
},
|
|
monthCell: {
|
|
width: (SCREEN_WIDTH - 100) / 3,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
monthCellText: {
|
|
fontSize: 15,
|
|
fontFamily: fontFamily.bold,
|
|
},
|
|
|
|
// ── Year picker list ─────────────────────────
|
|
yearList: {
|
|
maxHeight: 300,
|
|
},
|
|
yearListContent: {
|
|
paddingBottom: 8,
|
|
},
|
|
yearRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 20,
|
|
borderRadius: 10,
|
|
borderWidth: 1.5,
|
|
marginBottom: 4,
|
|
},
|
|
yearRowText: {
|
|
fontSize: 16,
|
|
},
|
|
});
|