Files
CBS-Mobile-App/src/components/DatePicker/index.tsx
T

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