diff --git a/components/add-class-modal.tsx b/components/add-class-modal.tsx index 6925ecd..e6759a8 100644 --- a/components/add-class-modal.tsx +++ b/components/add-class-modal.tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { useEffect, useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; import { AppText, @@ -33,8 +33,6 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false 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(''); @@ -42,6 +40,9 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedType, setSelectedType] = useState('All'); useEffect(() => { if (!visible) return; @@ -64,15 +65,49 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false 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)); + setIsPickerOpen(false); + setSearchQuery(''); + setSelectedType('All'); }) .catch((loadError: Error) => setError(loadError.message)) .finally(() => setLoading(false)); }, [visible, date, regular]); + const currentSubject = subjects.find((s) => s.id === subjectId) ?? subjects[0] ?? null; + + const availableTypes = useMemo(() => { + const types = new Set(); + subjects.forEach((s) => { + if (s.classType && s.classType.trim()) types.add(s.classType.trim()); + }); + return Array.from(types); + }, [subjects]); + + const filteredSubjects = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return subjects.filter((s) => { + const matchesQuery = + !query || + s.name.toLowerCase().includes(query) || + s.code.toLowerCase().includes(query) || + (s.shortName && s.shortName.toLowerCase().includes(query)); + const matchesType = selectedType === 'All' || s.classType === selectedType; + return matchesQuery && matchesType; + }); + }, [subjects, searchQuery, selectedType]); + + const selectSubject = (subj: Subject) => { + setSubjectId(subj.id); + if (!room || subjects.some((s) => s.defaultRoom === room)) { + setRoom(subj.defaultRoom ?? ''); + } + setIsPickerOpen(false); + setSearchQuery(''); + setError(''); + }; + const updateStart = (value: string) => { setStartTime(value); setError(''); @@ -127,30 +162,177 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false {!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} + {subjects.length === 0 ? ( + + ) : currentSubject ? ( + + {/* Selected Subject Card / Trigger */} + setIsPickerOpen((prev) => !prev)} + style={({ pressed }) => { + const tone = subjectToneFor(currentSubject.id, currentSubject.color); + const palette = colors.subject[tone]; + return [ + styles.selectedSubjectCard, + { backgroundColor: palette.surface, borderColor: palette.accent }, + pressed && styles.pressed, + ]; + }}> + + + + + {currentSubject.name} + + + + {currentSubject.code} + + {currentSubject.classType ? ( + <> + · + + {currentSubject.classType} + + + ) : null} + + + + + {isPickerOpen ? 'Done' : 'Change'} + + + - {selected ? : null} - ; - })} - } + + + {/* Expandable Subject Picker Drawer */} + {isPickerOpen ? ( + + {subjects.length > 4 ? ( + + + + {searchQuery ? ( + setSearchQuery('')} hitSlop={8}> + + + ) : null} + + ) : null} + + {availableTypes.length > 1 ? ( + + {['All', ...availableTypes].map((type) => { + const active = selectedType === type; + return ( + setSelectedType(type)} + style={[styles.typeChip, active && styles.typeChipActive]}> + + {type} + + + ); + })} + + ) : null} + + + {filteredSubjects.map((subj) => { + const isSelected = subj.id === subjectId; + const tone = subjectToneFor(subj.id, subj.color); + const palette = colors.subject[tone]; + return ( + selectSubject(subj)} + style={({ pressed }) => [ + styles.pickerRow, + isSelected && { backgroundColor: palette.surface }, + pressed && styles.pressed, + ]}> + + + + {subj.name} + + + {subj.code}{subj.classType ? ` · ${subj.classType}` : ''} + + + {isSelected ? ( + + ) : ( + + )} + + ); + })} + {filteredSubjects.length === 0 ? ( + + + No courses found matching “{searchQuery}” + + + ) : null} + + + ) : null} + + ) : null} Time - {lectureMinutes} min default · {recessEnabled ? `recess ${recess}` : 'recess off'} + + {[45, 50, 60, 90, 120].map((mins) => { + const currentDur = timeToMinutes(endTime) - timeToMinutes(startTime); + const isMatch = currentDur === mins; + return ( + { + if (validTime(startTime)) { + setEndTime(addMinutes(startTime, mins)); + } + }} + style={[styles.durationChip, isMatch && styles.durationChipActive]}> + + {mins}m + + + ); + })} + @@ -165,15 +347,135 @@ export function AddClassModal({ visible, onClose, onAdded, date, regular = false 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 }, + sectionLabel: { marginTop: spacing[5], marginBottom: spacing[2] }, + subjectSelectorWrap: { gap: spacing[2] }, + selectedSubjectCard: { + borderRadius: radius.card, + borderCurve: 'continuous', + overflow: 'hidden', + }, + selectedSubjectInner: { + minHeight: 64, + flexDirection: 'row', + alignItems: 'center', + gap: spacing[3], + paddingHorizontal: spacing[4], + paddingVertical: spacing[3], + borderRadius: radius.card, + borderCurve: 'continuous', + borderWidth: 1.5, + }, + selectedSubjectCopy: { flex: 1 }, + selectedSubjectMetaRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + changeBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing[3], + paddingVertical: 6, + borderRadius: radius.pill, + borderCurve: 'continuous', + }, + changeBadgeText: { fontWeight: '700', fontSize: 11 }, + pickerDrawer: { + backgroundColor: colors.neutral.surface, + borderRadius: radius.card, + borderCurve: 'continuous', + borderWidth: 1, + borderColor: colors.neutral.border, + padding: spacing[3], + gap: spacing[2], + }, + searchWrap: { + flexDirection: 'row', + alignItems: 'center', + height: 40, + backgroundColor: colors.neutral.surfaceSubtle, + borderRadius: radius.control, + borderCurve: 'continuous', + paddingHorizontal: spacing[3], + gap: spacing[2], + }, + searchIcon: { marginRight: 2 }, + searchInput: { + flex: 1, + height: '100%', + fontFamily: 'Manrope_500Medium', + fontSize: 13, + color: colors.neutral.textPrimary, + paddingVertical: 0, + }, + typeFilterRow: { + flexDirection: 'row', + gap: spacing[2], + paddingVertical: 2, + }, + typeChip: { + paddingHorizontal: spacing[3], + paddingVertical: 5, + borderRadius: radius.pill, + borderCurve: 'continuous', + backgroundColor: colors.neutral.surfaceSubtle, + }, + typeChipActive: { + backgroundColor: colors.brand.cobalt, + }, + typeChipText: { + fontWeight: '600', + fontSize: 11, + }, + pickerScroll: { + maxHeight: 180, + }, + pickerRow: { + minHeight: 46, + flexDirection: 'row', + alignItems: 'center', + gap: spacing[3], + paddingHorizontal: spacing[3], + paddingVertical: spacing[2], + borderRadius: radius.control, + borderCurve: 'continuous', + marginVertical: 1, + }, + pickerRowCopy: { flex: 1 }, + emptySearch: { + paddingVertical: spacing[5], + alignItems: 'center', + justifyContent: 'center', + }, 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] }, + timeHeading: { + marginTop: spacing[5], + marginBottom: spacing[2], + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + durationPresets: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing[1] + 2, + }, + durationChip: { + paddingHorizontal: spacing[2] + 2, + paddingVertical: 3, + borderRadius: radius.pill, + borderCurve: 'continuous', + backgroundColor: colors.neutral.surfaceSubtle, + }, + durationChipActive: { + backgroundColor: colors.brand.cobaltSoft, + }, + durationChipText: { + fontSize: 11, + fontWeight: '500', + }, + durationChipTextActive: { + fontSize: 11, + fontWeight: '700', + }, timeFields: { flexDirection: 'row', gap: spacing[3] }, timeField: { flex: 1 }, - roomField: { marginTop: spacing[5], marginBottom: spacing[2] }, + roomField: { marginTop: spacing[4], marginBottom: spacing[2] }, }); diff --git a/scripts/test-add-class-modal.mjs b/scripts/test-add-class-modal.mjs new file mode 100644 index 0000000..7b764dd --- /dev/null +++ b/scripts/test-add-class-modal.mjs @@ -0,0 +1,40 @@ +import { chromium } from 'playwright'; + +async function testModal() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + isMobile: true, + }); + const page = await context.newPage(); + + await page.goto('http://127.0.0.1:3000/'); + await page.waitForTimeout(2000); + + // Click Add a class button + const addBtn = page.getByRole('button', { name: 'Add a class' }); + await addBtn.click(); + await page.waitForTimeout(1000); + await page.screenshot({ path: './screenshots/13-add-class-modal-default.png' }); + + // Click Change subject button + const changeBtn = page.getByText('Change'); + if (await changeBtn.isVisible()) { + await changeBtn.click(); + await page.waitForTimeout(600); + await page.screenshot({ path: './screenshots/14-add-class-modal-picker-open.png' }); + + // Type into search + const searchInput = page.getByPlaceholder('Search courses by name or code...'); + if (await searchInput.isVisible()) { + await searchInput.fill('Data'); + await page.waitForTimeout(500); + await page.screenshot({ path: './screenshots/15-add-class-modal-search.png' }); + } + } + + await browser.close(); +} + +testModal().catch(console.error);