import { Ionicons } from '@expo/vector-icons'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { AppText, BottomSheet, Button, FormField, InlineBanner, SubjectBadge, colors, radius, spacing, } from '@/components/ui'; import { dateFromKey, formatDayHeading, formatWeekdayLong, timeToMinutes } from '@/lib/date'; import { subjectToneFor } from '@/lib/design'; import { collegeApi, type Subject } from '@/lib/api'; type Props = { visible: boolean; onClose: () => void; onAdded: () => void; date: string; regular?: boolean; weekday?: number }; type TimeRange = { start: string; end: string }; const subjectShortName = (subject: Subject) => subject.shortName || subject.name.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6); const addMinutes = (time: string, minutes: number) => { const [hours, mins] = time.split(':').map(Number); const total = ((hours * 60 + mins + minutes) % (24 * 60) + 24 * 60) % (24 * 60); return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; }; const skipRecess = (time: string, recessStart: string, recessEnd: string) => time >= recessStart && time < recessEnd ? recessEnd : time; const validTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value); export function AddClassModal({ visible, onClose, onAdded, date, regular = false, weekday }: Props) { const [subjects, setSubjects] = useState([]); const [subjectId, setSubjectId] = useState(null); const [lectureMinutes, setLectureMinutes] = useState(60); const [recessEnabled, setRecessEnabled] = useState(true); const [recess, setRecess] = useState('13:00–14:00'); const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); const [room, setRoom] = useState(''); const [existingRanges, setExistingRanges] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); useEffect(() => { if (!visible) return; setLoading(true); setError(''); const existing = regular ? collegeApi.timetable(date).then(({ classes }) => classes.map((item) => ({ start: item.startTime, end: item.endTime }))) : collegeApi.schedule(date).then(({ sessions }) => sessions.filter((item) => item.status !== 'cancelled').map((item) => ({ start: item.time, end: item.endTime }))); Promise.all([collegeApi.subjects(), collegeApi.profile(), existing]) .then(([items, profile, ranges]) => { setSubjects(items); setSubjectId(items[0]?.id ?? null); setRoom(items[0]?.defaultRoom ?? ''); setExistingRanges(ranges); const minutes = profile.lectureMinutes ?? 60; const recessIsEnabled = Boolean(profile.recessEnabled); const recessStart = profile.recessStart ?? '13:00'; const recessEnd = profile.recessEnd ?? '14:00'; const lastEnd = [...ranges].sort((left, right) => left.end.localeCompare(right.end)).at(-1)?.end; const nextStart = recessIsEnabled ? skipRecess(lastEnd || '09:00', recessStart, recessEnd) : (lastEnd || '09:00'); setLectureMinutes(minutes); setRecessEnabled(recessIsEnabled); setRecess(`${recessStart}–${recessEnd}`); setStartTime(nextStart); setEndTime(addMinutes(nextStart, minutes)); }) .catch((loadError: Error) => setError(loadError.message)) .finally(() => setLoading(false)); }, [visible, date, regular]); const updateStart = (value: string) => { setStartTime(value); setError(''); if (validTime(value)) setEndTime(addMinutes(value, lectureMinutes)); }; const save = async () => { if (!subjectId) { setError('Choose a subject before adding this class.'); return; } if (!validTime(startTime) || !validTime(endTime) || timeToMinutes(startTime) >= timeToMinutes(endTime)) { setError('Use valid times and make the end time later than the start time.'); return; } const overlaps = existingRanges.some((range) => timeToMinutes(startTime) < timeToMinutes(range.end) && timeToMinutes(endTime) > timeToMinutes(range.start)); if (overlaps) { setError('This time overlaps another class. Choose a different time range.'); return; } setSaving(true); setError(''); try { const location = room.trim() || 'TBA'; if (regular) await collegeApi.addTimetableClass({ subjectId, weekday: weekday ?? dateFromKey(date).getDay(), startTime, endTime, room: location, effectiveFrom: date }); else await collegeApi.addOneOffClass({ subjectId, date, startTime, endTime, room: location }); onAdded(); onClose(); setRoom(''); } catch (saveError) { setError(saveError instanceof Error ? saveError.message : 'Please try again.'); } finally { setSaving(false); } }; const title = regular ? 'Add recurring class' : 'Add a class'; const context = regular ? `Repeats on ${formatWeekdayLong(dateFromKey(date))}. Recurring changes begin in the future.` : `One-off class for ${formatDayHeading(dateFromKey(date))}.`; return } />}> {context} {error ? : null} {loading ? Loading subjects and times… : null} {!loading ? <> Subject {subjects.length === 0 ? : {subjects.map((subject) => { const tone = subjectToneFor(subject.id, subject.color); const selected = subject.id === subjectId; return { setSubjectId(subject.id); setRoom(subject.defaultRoom ?? ''); setError(''); }} style={({ pressed }) => [styles.subject, selected && { backgroundColor: colors.subject[tone].surface, borderColor: colors.subject[tone].accent }, pressed && styles.pressed]}> {subject.name} {subject.code} {selected ? : null} ; })} } Time {lectureMinutes} min default · {recessEnabled ? `recess ${recess}` : 'recess off'} { setEndTime(value); setError(''); }} placeholder="10:00" hint="HH:MM" containerStyle={styles.timeField} /> : null} ; } const styles = StyleSheet.create({ feedback: { marginTop: spacing[4] }, loading: { minHeight: 180, alignItems: 'center', justifyContent: 'center', gap: spacing[3] }, sectionLabel: { marginTop: spacing[6], marginBottom: spacing[3] }, subjects: { gap: spacing[2] }, subject: { minHeight: 58, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[3], paddingVertical: spacing[2], borderRadius: radius.card, borderCurve: 'continuous', borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface }, pressed: { opacity: 0.76 }, subjectCopy: { flex: 1 }, radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: colors.neutral.border, alignItems: 'center', justifyContent: 'center' }, radioDot: { width: 10, height: 10, borderRadius: 5 }, timeHeading: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', gap: spacing[3] }, timeFields: { flexDirection: 'row', gap: spacing[3] }, timeField: { flex: 1 }, roomField: { marginTop: spacing[5], marginBottom: spacing[2] }, });