Files
clg-kit/app/onboarding.tsx
root 693cc1c0b6 feat: redesign day picker with expandable calendar, status markers & Playwright E2E testing
- Replace modal calendar with inline expandable calendar widget integrated into the day picker.
- Add status indicator dots (attended/absent/pending) to month calendar grid matching day picker.
- Disable and gray out weekend days in calendar when weekend schedule is turned off.
- Redesign extend bar with integrated handle that aligns with the palette.
- Remove start-to-end timing on Today screen's class time rail, showing only start time.
- Implement Playwright E2E and visual testing suite with multi-screen capture scripts.
- Add cascade DELETE endpoints for subjects and versioned recurring timetable entries.
2026-09-05 08:58:51 +05:30

166 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import {
AppText,
Button,
Card,
FormField,
InlineBanner,
Screen,
SubjectBadge,
colors,
radius,
spacing,
type SubjectTone,
} from '@/components/ui';
import { collegeApi } from '@/lib/api';
type SubjectDraft = { name: string; code: string; shortName: string; tone: SubjectTone };
const tones: SubjectTone[] = ['ocean', 'aqua', 'lilac', 'sun', 'mint', 'peach'];
const makeShortName = (value: string) => value.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
export default function OnboardingScreen() {
const [step, setStep] = useState(1);
const [name, setName] = useState('');
const [college, setCollege] = useState('');
const [programme, setProgramme] = useState('');
const [semester, setSemester] = useState('');
const [subjectName, setSubjectName] = useState('');
const [subjectCode, setSubjectCode] = useState('');
const [subjects, setSubjects] = useState<SubjectDraft[]>([]);
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const continueToSubjects = () => {
if (!name.trim() || !college.trim()) {
setFormError('Your name and college are required.');
return;
}
setFormError('');
setStep(2);
};
const addSubject = () => {
const code = subjectCode.trim().toUpperCase();
if (!subjectName.trim() || !code) {
setFormError('Add both a subject name and code.');
return;
}
if (subjects.some((subject) => subject.code === code)) {
setFormError('That subject code is already in your list.');
return;
}
setSubjects((items) => [...items, { name: subjectName.trim(), code, shortName: makeShortName(subjectName), tone: tones[items.length % tones.length] }]);
setSubjectName('');
setSubjectCode('');
setFormError('');
};
const finish = async () => {
setSaving(true);
setFormError('');
try {
await collegeApi.updateProfile({ name, college, programme, semester });
for (const subject of subjects) await collegeApi.createSubject({ name: subject.name, code: subject.code, shortName: subject.shortName, color: colors.subject[subject.tone].accent });
router.replace('/');
} catch (error) {
setFormError(error instanceof Error ? error.message : 'Please try again.');
} finally {
setSaving(false);
}
};
return <Screen contentContainerStyle={styles.content}>
<View style={styles.brand}>
<View style={styles.logo}><Ionicons name="school" size={22} color={colors.neutral.surface} /></View>
<AppText variant="title">CLG Kit</AppText>
</View>
<View accessible accessibilityLabel={`Step ${step} of 2`} style={styles.progress}>
<View style={[styles.progressSegment, styles.progressActive]} />
<View style={[styles.progressSegment, step === 2 && styles.progressCurrent]} />
</View>
{step === 1 ? <>
<View style={styles.intro}>
<AppText variant="display">Build your college day.</AppText>
<AppText variant="body" color={colors.neutral.textSecondary} style={styles.introCopy}>Start with the essentials. Well use them to organize your timetable and attendance.</AppText>
</View>
{formError ? <InlineBanner title="Check your details" message={formError} tone="danger" style={styles.error} /> : null}
<View style={styles.fields}>
<FormField label="Your name" value={name} onChangeText={setName} placeholder="e.g. Sam Taylor" autoCapitalize="words" autoComplete="name" />
<FormField label="College or university" value={college} onChangeText={setCollege} placeholder="e.g. Northbridge University" autoCapitalize="words" />
<FormField label="Programme (optional)" value={programme} onChangeText={setProgramme} placeholder="e.g. B.Tech Computer Science" autoCapitalize="words" />
<FormField label="Semester (optional)" value={semester} onChangeText={setSemester} placeholder="e.g. Semester 3" autoCapitalize="words" />
</View>
<Button label="Continue" onPress={continueToSubjects} trailing={<Ionicons name="arrow-forward" size={18} color={colors.neutral.surface} />} />
</> : <>
<Pressable accessibilityRole="button" accessibilityLabel="Back to college details" onPress={() => { setStep(1); setFormError(''); }} style={styles.back}>
<Ionicons name="arrow-back" size={18} color={colors.brand.cobalt} />
<AppText variant="label" color={colors.brand.cobalt}>College details</AppText>
</Pressable>
<View style={styles.introSubjects}>
<AppText variant="heading1">Add your subjects.</AppText>
<AppText variant="body" color={colors.neutral.textSecondary} style={styles.introCopy}>Theyll keep the same color throughout your timetable and attendance views.</AppText>
</View>
{formError ? <InlineBanner title="Couldnt continue" message={formError} tone="danger" style={styles.error} /> : null}
<Card tone="skySoft" style={styles.subjectForm}>
<View style={styles.fieldsCompact}>
<FormField label="Subject name" value={subjectName} onChangeText={setSubjectName} placeholder="e.g. Data Structures" autoCapitalize="words" />
<FormField label="Subject code" value={subjectCode} onChangeText={(value) => setSubjectCode(value.toUpperCase())} placeholder="e.g. CS201" autoCapitalize="characters" />
</View>
<Button label="Add subject" variant="secondary" onPress={addSubject} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />
</Card>
{subjects.length > 0 ? <View style={styles.subjectSection}>
<AppText variant="heading3">Your subjects</AppText>
<View style={styles.subjects}>
{subjects.map((subject, index) => <Card key={`${subject.code}-${index}`} style={styles.subjectRow} padding={spacing[3]}>
<SubjectBadge shortName={subject.shortName || subject.code.slice(0, 3)} tone={subject.tone} />
<View style={styles.subjectCopy}>
<AppText variant="label" numberOfLines={2}>{subject.name}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.subjectMeta}>{subject.code} · {subject.shortName}</AppText>
</View>
<Pressable accessibilityRole="button" accessibilityLabel={`Remove ${subject.name}`} hitSlop={8} onPress={() => setSubjects((items) => items.filter((_, itemIndex) => itemIndex !== index))} style={styles.remove}>
<Ionicons name="close" size={19} color={colors.neutral.textMuted} />
</Pressable>
</Card>)}
</View>
</View> : null}
<Button label={subjects.length ? 'Finish setup' : 'Skip for now'} loading={saving} haptic="success" onPress={finish} trailing={<Ionicons name="checkmark" size={19} color={colors.neutral.surface} />} style={styles.finish} />
</>}
</Screen>;
}
const styles = StyleSheet.create({
content: { paddingTop: spacing[5], paddingBottom: spacing[9] },
brand: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
logo: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobalt },
progress: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[8] },
progressSegment: { flex: 1, height: 5, borderRadius: radius.pill, backgroundColor: colors.neutral.divider },
progressActive: { backgroundColor: colors.brand.cobalt },
progressCurrent: { backgroundColor: colors.brand.coral },
intro: { marginTop: spacing[9] },
introSubjects: { marginTop: spacing[7] },
introCopy: { marginTop: spacing[3], maxWidth: 440 },
error: { marginTop: spacing[5] },
fields: { gap: spacing[5], marginVertical: spacing[7] },
fieldsCompact: { gap: spacing[4], marginBottom: spacing[5] },
back: { minHeight: 44, marginTop: spacing[5], flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: spacing[2] },
subjectForm: { marginTop: spacing[7] },
subjectSection: { marginTop: spacing[7] },
subjects: { gap: spacing[2], marginTop: spacing[3] },
subjectRow: { minHeight: 68, flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
subjectCopy: { flex: 1 },
subjectMeta: { marginTop: spacing[1] },
remove: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
finish: { marginTop: spacing[7] },
});