Update app and ignore local artifacts

This commit is contained in:
2026-09-04 12:59:28 +05:30
parent 8120ebb927
commit e6884a148b
56 changed files with 4419 additions and 370 deletions

View File

@@ -1,35 +1,18 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { BottomTabBar } from '@/components/ui/bottom-tab-bar';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
);
return <Tabs
tabBar={(props) => <BottomTabBar {...props} />}
screenOptions={{
headerShown: false,
// The visible dock is independently absolute; this removes React Navigation's reserved tab-bar scene area.
tabBarStyle: { position: 'absolute', height: 0, backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0 },
}}>
<Tabs.Screen name="index" options={{ title: 'Today' }} />
<Tabs.Screen name="timetable" options={{ title: 'Timetable' }} />
<Tabs.Screen name="attendance" options={{ title: 'Attendance' }} />
<Tabs.Screen name="settings" options={{ title: 'Settings' }} />
</Tabs>;
}

163
app/(tabs)/attendance.tsx Normal file
View File

@@ -0,0 +1,163 @@
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import {
AppHeader,
AppText,
AttendanceRing,
Button,
Card,
EmptyState,
InlineBanner,
ProgressBar,
Screen,
StatusPill,
SubjectBadge,
colors,
spacing,
} from '@/components/ui';
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
import { collegeApi } from '@/lib/api';
type Subject = Awaited<ReturnType<typeof collegeApi.attendanceSummary>>['subjects'][number];
export default function AttendanceScreen() {
const router = useRouter();
const [subjects, setSubjects] = useState<Subject[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const load = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.attendanceSummary()
.then(({ subjects: result }) => setSubjects(result))
.catch((error: Error) => { setSubjects([]); setLoadError(error.message); })
.finally(() => setLoading(false));
}, []);
useFocusEffect(useCallback(() => { load(); }, [load]));
const totalHeld = subjects.reduce((total, subject) => total + subject.total, 0);
const totalAttended = subjects.reduce((total, subject) => total + subject.attended, 0);
const totalMissed = subjects.reduce((total, subject) => total + subject.absent, 0);
const overall = totalHeld ? Math.round(totalAttended / totalHeld * 100) : 0;
const overallTone = attendanceTone(overall, totalHeld);
const insight = attendanceMessage(overall, totalHeld);
const sortedSubjects = [...subjects].sort((left, right) => {
if (!left.total && right.total) return 1;
if (left.total && !right.total) return -1;
return left.percentage - right.percentage || left.name.localeCompare(right.name);
});
return <Screen contentContainerStyle={styles.content}>
<AppHeader title="Attendance" />
{loadError ? <InlineBanner title="Couldnt load attendance" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} style={styles.firstBlock} /> : null}
{loading ? <Card tone="skySoft" style={[styles.loading, styles.firstBlock]}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Calculating attendance</AppText></Card> : null}
{!loading && !loadError ? <>
<Card tone="sky" padding={spacing[6]} style={styles.hero}>
<AttendanceRing
percentage={overall}
tone={totalHeld ? (overallTone === 'neutral' ? 'brand' : overallTone) : 'brand'}
label={totalHeld ? 'Overall' : 'No data'}
accessibilityLabel={totalHeld ? `Overall attendance ${overall} percent` : 'No attendance data yet'}
/>
<View style={styles.heroCopy}>
<AppText variant="heading3">{insight.title}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.heroMessage}>{insight.message}</AppText>
{totalHeld ? <StatusPill
label={overallTone === 'success' ? 'On track' : overallTone === 'warning' ? 'Close to target' : 'Below target'}
tone={overallTone}
icon={overallTone === 'success' ? 'shield-checkmark' : 'alert-circle'}
style={styles.heroStatus}
/> : null}
</View>
</Card>
<Card style={styles.stats} padding={0}>
<Stat value={totalHeld} label="Classes held" />
<View style={styles.statDivider} />
<Stat value={totalAttended} label="Attended" />
<View style={styles.statDivider} />
<Stat value={totalMissed} label="Missed" />
</Card>
<View style={styles.sectionHeader}>
<View>
<AppText variant="heading2">By subject</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>Subjects needing attention appear first</AppText>
</View>
</View>
{subjects.length === 0 ? <EmptyState icon="pie-chart-outline" title="No attendance yet" message="Add subjects and classes, then mark attendance from Today." /> : <Card padding={0} style={styles.subjectList}>
{sortedSubjects.map((subject, index) => {
const tone = subjectToneFor(subject.id, subject.color);
const palette = colors.subject[tone];
const state = attendanceTone(subject.percentage, subject.total);
const atRisk = subject.total > 0 && state !== 'success';
return <Pressable
key={subject.id}
accessibilityRole="button"
accessibilityLabel={`${subject.name}, ${subject.percentage} percent attendance, ${subject.attended} of ${subject.total} classes attended`}
onPress={() => router.push(`/subjects/${subject.id}` as never)}
style={({ pressed }) => [styles.subject, pressed && styles.subjectPressed]}>
<SubjectBadge shortName={subject.shortName || subject.code.slice(0, 3)} tone={tone} />
<View style={styles.subjectBody}>
<View style={styles.subjectTop}>
<View style={styles.subjectTitleWrap}>
<AppText variant="title" numberOfLines={2}>{subject.name}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.subjectMeta}>{subject.code} · {subject.attended}/{subject.total} attended</AppText>
</View>
<AppText variant="title" color={atRisk ? colors.semantic[state].text : palette.accent} style={styles.percentage}>{subject.total ? `${subject.percentage}%` : '—'}</AppText>
</View>
<ProgressBar value={subject.total ? subject.percentage : 0} color={palette.accent} accessibilityLabel={`${subject.name} attendance`} style={styles.progress} />
{atRisk ? <StatusPill label={state === 'warning' ? 'Close to target' : 'Needs attention'} tone={state} icon="alert-circle" style={styles.subjectStatus} /> : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.neutral.textMuted} />
{index < sortedSubjects.length - 1 ? <View style={styles.subjectDivider} /> : null}
</Pressable>;
})}
</Card>}
</> : null}
</Screen>;
}
function Stat({ value, label }: { value: number; label: string }) {
return <View style={styles.stat}>
<AppText variant="heading3" style={styles.tabular}>{value}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.statLabel}>{label}</AppText>
</View>;
}
const styles = StyleSheet.create({
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
firstBlock: { marginTop: spacing[6] },
loading: { minHeight: 144, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
hero: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'center', gap: spacing[6], borderRadius: 20 },
heroCopy: { flex: 1 },
heroMessage: { marginTop: spacing[2] },
heroStatus: { marginTop: spacing[4] },
stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
statDivider: { width: 1, height: 36, backgroundColor: colors.neutral.divider },
statLabel: { marginTop: spacing[1], textAlign: 'center' },
tabular: { fontVariant: ['tabular-nums'] },
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4] },
sectionSub: { marginTop: spacing[1] },
subjectList: { overflow: 'hidden' },
subject: { minHeight: 100, flexDirection: 'row', alignItems: 'center', gap: spacing[3], padding: spacing[4] },
subjectPressed: { backgroundColor: colors.brand.skySoft },
subjectBody: { flex: 1 },
subjectTop: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3] },
subjectTitleWrap: { flex: 1 },
subjectMeta: { marginTop: spacing[1] },
percentage: { fontVariant: ['tabular-nums'] },
progress: { marginTop: spacing[3] },
subjectStatus: { marginTop: spacing[3] },
subjectDivider: { position: 'absolute', left: 72, right: spacing[4], bottom: 0, height: 1, backgroundColor: colors.neutral.divider },
});

View File

@@ -1,112 +0,0 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Fonts } from '@/constants/theme';
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}>
Explore
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{' '}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

View File

@@ -1,98 +1,313 @@
import { Image } from 'expo-image';
import { Platform, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { HelloWave } from '@/components/hello-wave';
import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Link } from 'expo-router';
import { AddClassModal } from '@/components/add-class-modal';
import {
AppHeader,
AppText,
BottomSheet,
Button,
Card,
CurrentTimeIndicator,
EmptyState,
IconButton,
InlineBanner,
RecessCard,
ScheduleEventCard,
Screen,
WeekStrip,
colors,
radius,
size,
spacing,
type WeekDay,
} from '@/components/ui';
import { addDays, currentMinutes, dateKey, formatDayHeading, mondayOfWeek, timeToMinutes } from '@/lib/date';
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api';
export default function HomeScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={
<Image
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
web: 'F12',
})}
</ThemedText>{' '}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href="/modal">
<Link.Trigger>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={() => alert('Share pressed')}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => alert('Delete pressed')}
/>
</Link.Menu>
</Link.Menu>
</Link>
type DayMarker = WeekDay['marker'];
<ThemedText>
{`Tap the Explore tab to learn more about what's included in this starter app.`}
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
{`When you're ready, run `}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
);
type AttendanceSummary = { total: number; attended: number; percentage: number };
const emptySummary: AttendanceSummary = { total: 0, attended: 0, percentage: 0 };
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
const daysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
export default function TodayScreen() {
const router = useRouter();
const [activeDate, setActiveDate] = useState(() => new Date());
const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
const [classes, setClasses] = useState<Session[]>([]);
const [profile, setProfile] = useState<Profile | null>(null);
const [summary, setSummary] = useState<AttendanceSummary>(emptySummary);
const [weekendSchedule, setWeekendSchedule] = useState(false);
const [weekMarkers, setWeekMarkers] = useState<Record<string, DayMarker>>({});
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [refresh, setRefresh] = useState(0);
const [calendarOpen, setCalendarOpen] = useState(false);
const [addClassOpen, setAddClassOpen] = useState(false);
const [selectedClass, setSelectedClass] = useState<Session | null>(null);
const today = new Date();
const todayKey = dateKey(today);
const selectedDateKey = dateKey(activeDate);
const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
const weekDates = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)), [weekStart]);
useFocusEffect(useCallback(() => {
let active = true;
collegeApi.profile().then((result) => {
if (!active) return;
setProfile(result);
setWeekendSchedule(Boolean(result.weekendSchedule));
if (!result.weekendSchedule) setActiveDate((current) => current.getDay() === 0 || current.getDay() === 6 ? mondayOfWeek(current) : current);
}).catch(() => undefined);
return () => { active = false; };
}, []));
const loadSchedule = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.schedule(selectedDateKey)
.then(({ sessions }) => setClasses(sessions))
.catch((error: Error) => { setClasses([]); setLoadError(error.message); })
.finally(() => setLoading(false));
}, [selectedDateKey]);
useEffect(() => { loadSchedule(); }, [loadSchedule, refresh]);
useEffect(() => {
collegeApi.attendanceSummary().then(({ subjects }) => {
const total = subjects.reduce((value, subject) => value + subject.total, 0);
const attended = subjects.reduce((value, subject) => value + subject.attended, 0);
setSummary({ total, attended, percentage: total ? Math.round(attended / total * 100) : 0 });
}).catch(() => setSummary(emptySummary));
}, [refresh]);
useEffect(() => {
Promise.all(weekDates.map((date) => collegeApi.schedule(dateKey(date)).then(({ sessions }) => ({ date, sessions })).catch(() => ({ date, sessions: [] as Session[] }))))
.then((results) => {
const markers: Record<string, DayMarker> = {};
results.forEach(({ date, sessions }) => {
const key = dateKey(date);
if (key > todayKey) return;
const held = sessions.filter((session) => session.status !== 'cancelled');
if (!held.length) return;
if (held.every((session) => session.status === 'attended')) markers[key] = 'success';
else if (held.some((session) => session.status === 'absent')) markers[key] = 'danger';
else if (held.some((session) => session.status === 'pending')) markers[key] = 'warning';
else markers[key] = 'neutral';
});
setWeekMarkers(markers);
});
}, [refresh, todayKey, weekDates]);
const visibleDays: WeekDay[] = weekDates
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
.map((date) => ({ date, marker: weekMarkers[dateKey(date)] ?? 'none' }));
const updateStatus = (session: Session, status: AttendanceStatus) => {
const previous = classes;
setClasses((items) => items.map((item) => item.id === session.id ? { ...item, status } : item));
collegeApi.markAttendance(session.id, status)
.then(() => {
if (Platform.OS !== 'web') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
})
.catch((error: Error) => {
setClasses(previous);
Alert.alert('Couldnt save attendance', error.message);
});
};
const removeClass = (session: Session) => {
Alert.alert('Remove this class?', 'This removes only this occurrence. Your recurring timetable will not change.', [
{ text: 'Keep class', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => {
setSelectedClass(null);
setClasses((items) => items.filter((item) => item.id !== session.id));
collegeApi.removeClass(session.id).then(() => setRefresh((value) => value + 1)).catch((error: Error) => {
Alert.alert('Couldnt remove class', error.message);
setRefresh((value) => value + 1);
});
} },
]);
};
const isToday = selectedDateKey === todayKey;
const insight = attendanceMessage(summary.percentage, summary.total);
const insightTone = attendanceTone(summary.percentage, summary.total);
const now = currentMinutes();
const recessEnabled = Boolean(profile && profile.recessEnabled && timeToMinutes(profile.recessStart) >= 0 && timeToMinutes(profile.recessStart) < timeToMinutes(profile.recessEnd));
const timelineItems = [
...classes.map((item) => ({ type: 'class' as const, start: item.time, end: item.endTime, item })),
...(recessEnabled ? [{ type: 'recess' as const, start: profile!.recessStart, end: profile!.recessEnd }] : []),
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
const activeTimelineIndex = isToday ? timelineItems.findIndex((item) => timeToMinutes(item.start) <= now && now < timeToMinutes(item.end)) : -1;
const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1;
const currentTimeLabel = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date());
const pendingCount = classes.filter((item) => item.status === 'pending').length;
return <Screen scroll={false} contentContainerStyle={styles.content}>
<View style={styles.stickyHeader}>
<AppHeader title={new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'short', day: 'numeric' }).format(activeDate)} />
<View style={styles.dateActions}>
{!isToday ? <Button label="Today" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
<IconButton icon="calendar-outline" label="Open calendar" tone="sky" onPress={() => { setCalendarMonth(startOfMonth(activeDate)); setCalendarOpen(true); }} />
</View>
<WeekStrip days={visibleDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
{summary.total > 0 ? <InlineBanner title={insight.title} message={insight.message} tone={insightTone} style={styles.insight} /> : null}
<View style={styles.sectionHeader}>
<View style={styles.sectionCopy}>
<AppText variant="heading2">{isToday ? 'Todays classes' : `${new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate)}s classes`}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSubtitle}>
{classes.length ? `${classes.length} ${classes.length === 1 ? 'class' : 'classes'}${pendingCount ? ` · ${pendingCount} to mark` : ''}` : 'Your agenda for this date'}
</AppText>
</View>
<IconButton icon="add" label="Add a class" onPress={() => setAddClassOpen(true)} />
</View>
</View>
<ScrollView style={styles.classList} contentContainerStyle={styles.classListContent} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{loadError ? <InlineBanner title="Couldnt load this day" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={loadSchedule} />} /> : null}
{loading ? <Card tone="skySoft" style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading your classes</AppText></Card> : null}
{!loading && !loadError && classes.length === 0 && !recessEnabled ? <EmptyState icon="calendar-clear-outline" title="No classes scheduled" message="Enjoy the break, or add a one-off class for this date." action={<Button label="Add a class" variant="secondary" onPress={() => setAddClassOpen(true)} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />} /> : null}
{!loading && !loadError && timelineItems.length > 0 ? <View style={styles.timeline}>
{timelineItems.map((entry, index) => {
const isNow = index === activeTimelineIndex;
const key = entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`;
return <View key={key}>
{index === insertionIndex ? <CurrentTimeIndicator time={currentTimeLabel} /> : null}
<View style={styles.classRow}>
<View style={styles.timeRail}>
<AppText variant="label" style={styles.timeText}>{entry.start}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.timeText}>{entry.end}</AppText>
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
</View>
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}${entry.end}`} isNow={isNow} style={styles.event} /> : <ScheduleEventCard
title={entry.item.title}
kind={entry.item.code}
classType={entry.item.classType}
timeRange={`${entry.start}${entry.end}`}
room={entry.item.room}
subjectTone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)}
state={entry.item.status}
isNow={isNow}
style={styles.event}
topAction={<IconButton icon="ellipsis-horizontal" label={`Options for ${entry.item.title}`} tone="ghost" onPress={() => setSelectedClass(entry.item)} />}
footer={entry.item.status !== 'cancelled' ? <AttendanceActions tone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)} onAttended={() => updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null}
/>}
</View>
</View>;
})}
{isToday && insertionIndex === -1 && activeTimelineIndex === -1 ? <CurrentTimeIndicator time={currentTimeLabel} /> : null}
</View> : null}
</ScrollView>
<AddClassModal visible={addClassOpen} date={selectedDateKey} onClose={() => setAddClassOpen(false)} onAdded={() => setRefresh((value) => value + 1)} />
<BottomSheet visible={Boolean(selectedClass)} title="Class options" onClose={() => setSelectedClass(null)}>
{selectedClass ? <>
<Card tone="skySoft" style={styles.selectedClassSummary}>
<AppText variant="title">{selectedClass.title}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.sectionSubtitle}>{selectedClass.code} · {selectedClass.time}{selectedClass.endTime} · {selectedClass.room}</AppText>
</Card>
<View style={styles.sheetButtons}>
<Button label="View subject" variant="secondary" disabled={!selectedClass.subjectId} onPress={() => { const id = selectedClass.subjectId; setSelectedClass(null); if (id) router.push(`/subjects/${id}` as never); }} leading={<Ionicons name="book-outline" size={18} color={colors.brand.cobalt} />} />
{selectedClass.status !== 'cancelled' ? <Button label="Cancel class" variant="ghost" onPress={() => { updateStatus(selectedClass, 'cancelled'); setSelectedClass(null); }} leading={<Ionicons name="remove-circle-outline" size={18} color={colors.semantic.danger.text} />} /> : null}
<Button label="Remove from this day" variant="ghost" onPress={() => removeClass(selectedClass)} leading={<Ionicons name="trash-outline" size={18} color={colors.semantic.danger.text} />} />
</View>
</> : null}
</BottomSheet>
<CalendarSheet
visible={calendarOpen}
month={calendarMonth}
selectedDateKey={selectedDateKey}
todayKey={todayKey}
onMonthChange={setCalendarMonth}
onClose={() => setCalendarOpen(false)}
onSelect={(date) => { setActiveDate(date); setCalendarOpen(false); }}
/>
</Screen>;
}
function AttendanceActions({ tone, onAttended, onAbsent }: { tone: keyof typeof colors.subject; onAttended: () => void; onAbsent: () => void }) {
const palette = colors.subject[tone];
return <View style={styles.attendanceActions}>
<Pressable accessibilityRole="button" accessibilityLabel="Mark attended" onPress={onAttended} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
<Ionicons name="checkmark" size={18} color={palette.accent} />
</Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Mark absent" onPress={onAbsent} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
<Ionicons name="close" size={18} color={palette.accent} />
</Pressable>
</View>;
}
function CalendarSheet({ visible, month, selectedDateKey, todayKey, onMonthChange, onClose, onSelect }: { visible: boolean; month: Date; selectedDateKey: string; todayKey: string; onMonthChange: (date: Date) => void; onClose: () => void; onSelect: (date: Date) => void }) {
const offset = (month.getDay() + 6) % 7;
const dates = Array.from({ length: offset + daysInMonth(month) }, (_, index) => index < offset ? null : index - offset + 1);
const weekdayLabels = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(addDays(mondayOfWeek(new Date()), index)));
return <BottomSheet visible={visible} title="Choose a date" onClose={onClose}>
<View style={styles.monthNavigation}>
<IconButton icon="chevron-back" label="Previous month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() - 1, 1))} />
<AppText variant="title">{new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(month)}</AppText>
<IconButton icon="chevron-forward" label="Next month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() + 1, 1))} />
</View>
<View style={styles.calendarWeekdays}>{weekdayLabels.map((label, index) => <AppText key={`${label}-${index}`} variant="caption" color={colors.neutral.textMuted} style={styles.calendarWeekday}>{label}</AppText>)}</View>
<View style={styles.calendarGrid}>{dates.map((day, index) => {
if (!day) return <View key={`empty-${index}`} style={styles.calendarCell} />;
const date = new Date(month.getFullYear(), month.getMonth(), day);
const key = dateKey(date);
const selected = key === selectedDateKey;
const isToday = key === todayKey;
return <Pressable key={key} accessibilityRole="button" accessibilityLabel={formatDayHeading(date)} accessibilityState={{ selected }} onPress={() => onSelect(date)} style={[styles.calendarCell, selected && styles.calendarSelected, isToday && !selected && styles.calendarToday]}>
<AppText variant="label" color={selected ? colors.brand.ink : colors.neutral.textPrimary} style={styles.timeText}>{day}</AppText>
</Pressable>;
})}</View>
<Button label="Go to today" variant="secondary" onPress={() => onSelect(new Date())} leading={<Ionicons name="locate-outline" size={18} color={colors.brand.cobalt} />} style={styles.calendarButton} />
</BottomSheet>;
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: 'absolute',
},
// Bottom padding belongs to the scrollable class content, not this fixed shell.
content: { paddingTop: spacing[1], paddingBottom: 0 },
stickyHeader: { flexShrink: 0 },
dateActions: { minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: spacing[1], marginBottom: spacing[3] },
insight: { marginTop: spacing[5] },
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
sectionCopy: { flex: 1 },
sectionSubtitle: { marginTop: spacing[1] },
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
timeline: { gap: spacing[2] },
classRow: { flexDirection: 'row', alignItems: 'stretch' },
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
timeText: { fontVariant: ['tabular-nums'] },
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
classList: { flex: 1, minHeight: 0 },
classListContent: { paddingTop: spacing[4], paddingBottom: size.tabBar + spacing[6] },
event: { flex: 1, marginBottom: spacing[2] },
attendanceActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
attendanceButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, borderWidth: 1, backgroundColor: 'rgba(255, 255, 255, 0.62)' },
actionPressed: { opacity: 0.72 },
sheetButtons: { gap: spacing[2], marginTop: spacing[5] },
selectedClassSummary: { marginTop: spacing[2] },
monthNavigation: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing[5] },
calendarWeekdays: { flexDirection: 'row', marginBottom: spacing[2] },
calendarWeekday: { width: '14.2857%', textAlign: 'center' },
calendarGrid: { flexDirection: 'row', flexWrap: 'wrap' },
calendarCell: { width: '14.2857%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
calendarSelected: { backgroundColor: colors.brand.coral },
calendarToday: { borderWidth: 2, borderColor: colors.brand.cobalt },
calendarButton: { marginTop: spacing[5] },
});

1
app/(tabs)/settings.tsx Normal file
View File

@@ -0,0 +1 @@
export { default } from '../settings';

161
app/(tabs)/timetable.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { AddClassModal } from '@/components/add-class-modal';
import {
AppHeader,
AppText,
Button,
Card,
EmptyState,
IconButton,
InlineBanner,
RecessCard,
ScheduleEventCard,
Screen,
WeekStrip,
colors,
spacing,
type WeekDay,
} from '@/components/ui';
import { addDays, dateKey, formatDayHeading, mondayOfWeek } from '@/lib/date';
import { subjectToneFor } from '@/lib/design';
import { collegeApi } from '@/lib/api';
type TimetableClass = Awaited<ReturnType<typeof collegeApi.timetable>>['classes'][number];
export default function TimetableScreen() {
const router = useRouter();
const [activeDate, setActiveDate] = useState(() => {
const today = new Date();
return today.getDay() === 0 || today.getDay() === 6 ? mondayOfWeek(today) : today;
});
const [schedule, setSchedule] = useState<TimetableClass[]>([]);
const [weekendSchedule, setWeekendSchedule] = useState(false);
const [recess, setRecess] = useState<{ enabled: boolean; start: string; end: string }>({ enabled: false, start: '13:00', end: '14:00' });
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [addClassOpen, setAddClassOpen] = useState(false);
const [refresh, setRefresh] = useState(0);
const selectedDateKey = dateKey(activeDate);
const todayKey = dateKey(new Date());
const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
const weekDays: WeekDay[] = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index))
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
.map((date) => ({ date })), [weekStart, weekendSchedule]);
useFocusEffect(useCallback(() => {
let active = true;
collegeApi.profile().then((profile) => {
if (!active) return;
setWeekendSchedule(Boolean(profile.weekendSchedule));
setRecess({ enabled: Boolean(profile.recessEnabled), start: profile.recessStart, end: profile.recessEnd });
}).catch(() => undefined);
return () => { active = false; };
}, []));
const load = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.timetable(selectedDateKey)
.then(({ classes }) => setSchedule(classes))
.catch((error: Error) => { setSchedule([]); setLoadError(error.message); })
.finally(() => setLoading(false));
}, [selectedDateKey]);
useEffect(() => { load(); }, [load, refresh]);
const moveWeek = (amount: number) => setActiveDate(addDays(activeDate, amount * 7));
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate);
const showRecess = recess.enabled && recess.start < recess.end;
const timelineItems = [
...schedule.map((item) => ({ type: 'class' as const, start: item.startTime, end: item.endTime, item })),
...(showRecess ? [{ type: 'recess' as const, start: recess.start, end: recess.end }] : []),
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
return <Screen contentContainerStyle={styles.content}>
<AppHeader title="Timetable" />
<View style={styles.weekToolbar}>
<IconButton icon="chevron-back" label="Previous week" tone="ghost" onPress={() => moveWeek(-1)} />
<View style={styles.weekCopy}>
<AppText variant="label">Week of {new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(weekStart)}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted}>{new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(activeDate)}</AppText>
</View>
<View style={styles.toolbarActions}>
{todayKey !== selectedDateKey ? <Button label="This week" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
<IconButton icon="chevron-forward" label="Next week" tone="ghost" onPress={() => moveWeek(1)} />
</View>
</View>
<WeekStrip days={weekDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
<View style={styles.sectionHeader}>
<View style={styles.sectionCopy}>
<AppText variant="heading2">{weekday}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>{formatDayHeading(activeDate)} · {schedule.length} {schedule.length === 1 ? 'class' : 'classes'}</AppText>
</View>
<IconButton icon="add" label={`Add a recurring class on ${weekday}`} onPress={() => setAddClassOpen(true)} />
</View>
{loadError ? <InlineBanner title="Couldnt load the timetable" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} /> : null}
{loading ? <Card tone="skySoft" style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading timetable</AppText></Card> : null}
{!loading && !loadError && schedule.length === 0 && !showRecess ? <EmptyState icon="calendar-outline" title={`No classes on ${weekday}`} message="Add a recurring class to build this days timetable." action={<Button label="Add recurring class" variant="secondary" onPress={() => setAddClassOpen(true)} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />} /> : null}
{!loading && !loadError && timelineItems.length > 0 ? <View style={styles.timeline}>
{timelineItems.map((entry, index) => <View key={entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`} style={styles.classRow}>
<View style={styles.timeRail}>
<AppText variant="label" style={styles.tabular}>{entry.start}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.tabular}>{entry.end}</AppText>
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
</View>
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}${entry.end}`} style={styles.event} /> : <ScheduleEventCard
title={entry.item.subjectName}
kind={entry.item.code}
classType={entry.item.classType}
timeRange={`${entry.start}${entry.end}`}
room={entry.item.room}
subjectTone={subjectToneFor(entry.item.subjectId, entry.item.color)}
style={styles.event}
onPress={() => router.push(`/subjects/${entry.item.subjectId}` as never)}
/>}
</View>)}
</View> : null}
<InlineBanner
title="Recurring changes start in the future"
message="Past classes and attendance records stay unchanged. Make one-day changes from Today."
tone="neutral"
style={styles.note}
/>
<AddClassModal
visible={addClassOpen}
regular
date={selectedDateKey}
weekday={activeDate.getDay()}
onClose={() => setAddClassOpen(false)}
onAdded={() => setRefresh((value) => value + 1)}
/>
</Screen>;
}
const styles = StyleSheet.create({
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
weekToolbar: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
weekCopy: { flex: 1, alignItems: 'center' },
toolbarActions: { flexDirection: 'row', alignItems: 'center' },
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
sectionCopy: { flex: 1 },
sectionSub: { marginTop: spacing[1] },
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
timeline: { gap: spacing[2] },
classRow: { flexDirection: 'row', alignItems: 'stretch' },
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
tabular: { fontVariant: ['tabular-nums'] },
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
event: { flex: 1, marginBottom: spacing[2] },
note: { marginTop: spacing[6] },
});

View File

@@ -1,24 +1,73 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as SplashScreen from 'expo-splash-screen';
import {
Manrope_400Regular,
Manrope_500Medium,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
} from '@expo-google-fonts/manrope';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { colors } from '@/components/ui';
import { collegeApi } from '@/lib/api';
void SplashScreen.preventAutoHideAsync();
const navigationTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: colors.brand.cobalt,
background: colors.neutral.canvas,
card: colors.neutral.surface,
text: colors.neutral.textPrimary,
border: colors.neutral.divider,
notification: colors.brand.coral,
},
};
export const unstable_settings = {
anchor: '(tabs)',
};
export default function RootLayout() {
const colorScheme = useColorScheme();
const router = useRouter();
const segments = useSegments();
const [fontsLoaded, fontError] = useFonts({
Manrope_400Regular,
Manrope_500Medium,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
});
useEffect(() => {
if (fontsLoaded || fontError) void SplashScreen.hideAsync();
}, [fontError, fontsLoaded]);
useEffect(() => {
collegeApi.profile().then((profile) => {
if ((!profile.name || !profile.college) && String(segments[0]) !== 'onboarding') router.replace('/onboarding' as never);
}).catch(() => undefined);
}, [router, segments]);
if (!fontsLoaded && !fontError) return null;
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<ThemeProvider value={navigationTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="account" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="subjects/[id]" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
<StatusBar style="dark" />
</ThemeProvider>
);
}

142
app/account.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import {
AppHeader,
AppText,
Button,
Card,
FormField,
IconButton,
InlineBanner,
Screen,
colors,
radius,
spacing,
} from '@/components/ui';
import { collegeApi, type Profile } from '@/lib/api';
export default function AccountScreen() {
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [editing, setEditing] = useState(false);
const [name, setName] = useState('');
const [college, setCollege] = useState('');
const [programme, setProgramme] = useState('');
const [semester, setSemester] = useState('');
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const load = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.profile()
.then((result) => {
setProfile(result);
setName(result.name);
setCollege(result.college);
setProgramme(result.programme);
setSemester(result.semester);
})
.catch((error: Error) => setLoadError(error.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!name.trim() || !college.trim()) {
setFormError('Your name and college are required.');
return;
}
setSaving(true);
setFormError('');
try {
const updated = await collegeApi.updateProfile({ name, college, programme, semester });
setProfile(updated);
setEditing(false);
} catch (error) {
setFormError(error instanceof Error ? error.message : 'Please try again.');
} finally {
setSaving(false);
}
};
return <Screen contentContainerStyle={styles.content}>
<AppHeader
compact
title="Account"
leading={<IconButton icon="chevron-back" label="Go back" onPress={() => router.back()} />}
trailing={profile ? <IconButton icon={editing ? 'close' : 'create-outline'} label={editing ? 'Cancel editing' : 'Edit profile'} onPress={() => { setEditing((value) => !value); setFormError(''); }} /> : undefined}
/>
{loading ? <View style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading profile</AppText></View> : null}
{loadError ? <InlineBanner title="Couldnt load your account" message={loadError} tone="danger" action={<Button label="Retry" variant="ghost" size="compact" fullWidth={false} onPress={load} />} style={styles.error} /> : null}
{profile ? <>
<View style={styles.profileHero}>
<View style={styles.avatar}>
<AppText variant="heading2">{profile.initials || '?'}</AppText>
<View style={styles.avatarDot} />
</View>
<AppText variant="heading2" style={styles.name}>{profile.name}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.college}>{profile.college}</AppText>
</View>
{editing ? <Card style={styles.editCard}>
<AppText variant="heading3">Profile details</AppText>
{formError ? <InlineBanner title="Check your details" message={formError} tone="danger" style={styles.formError} /> : null}
<View style={styles.fields}>
<FormField label="Your name" value={name} onChangeText={setName} placeholder="Your name" autoCapitalize="words" autoComplete="name" />
<FormField label="College or university" value={college} onChangeText={setCollege} placeholder="College or 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="Save profile" loading={saving} haptic="success" onPress={save} />
</Card> : <>
<AppText variant="heading3" style={styles.sectionTitle}>College details</AppText>
<Card padding={0} style={styles.details}>
<Info icon="school-outline" label="College" value={profile.college || 'Not added'} />
<View style={styles.divider} />
<Info icon="book-outline" label="Programme" value={profile.programme || 'Not added'} />
<View style={styles.divider} />
<Info icon="calendar-outline" label="Current semester" value={profile.semester || 'Not added'} />
</Card>
</>}
</> : null}
</Screen>;
}
function Info({ icon, label, value }: { icon: keyof typeof Ionicons.glyphMap; label: string; value: string }) {
return <View style={styles.info}>
<View style={styles.infoIcon}><Ionicons name={icon} size={20} color={colors.brand.cobalt} /></View>
<View style={styles.infoCopy}>
<AppText variant="caption" color={colors.neutral.textMuted}>{label}</AppText>
<AppText variant="label" style={styles.infoValue}>{value}</AppText>
</View>
</View>;
}
const styles = StyleSheet.create({
content: { paddingBottom: spacing[9] },
loading: { minHeight: 220, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
error: { marginTop: spacing[6] },
profileHero: { marginTop: spacing[5], alignItems: 'center', borderRadius: radius.feature, backgroundColor: colors.brand.sky, padding: spacing[7] },
avatar: { width: 82, height: 82, borderRadius: 27, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
avatarDot: { position: 'absolute', right: 2, top: 2, width: 14, height: 14, borderRadius: 7, backgroundColor: colors.brand.coral, borderWidth: 3, borderColor: colors.neutral.surface },
name: { marginTop: spacing[4], textAlign: 'center' },
college: { marginTop: spacing[1], textAlign: 'center' },
editCard: { marginTop: spacing[4] },
formError: { marginTop: spacing[4] },
fields: { gap: spacing[4], marginVertical: spacing[5] },
sectionTitle: { marginTop: spacing[8], marginBottom: spacing[3] },
details: { overflow: 'hidden' },
info: { minHeight: 78, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[4], paddingVertical: spacing[3] },
infoIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
infoCopy: { flex: 1 },
infoValue: { marginTop: spacing[1] },
divider: { height: 1, marginLeft: 68, marginRight: spacing[4], backgroundColor: colors.neutral.divider },
});

View File

@@ -1,29 +0,0 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

165
app/onboarding.tsx Normal file
View File

@@ -0,0 +1,165 @@
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, 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] },
});

184
app/settings.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { Ionicons } from '@expo/vector-icons';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Switch, View } from 'react-native';
import {
AppHeader,
AppText,
Button,
Card,
FormField,
InlineBanner,
Screen,
colors,
radius,
spacing,
} from '@/components/ui';
import { collegeApi } from '@/lib/api';
export default function SettingsScreen() {
const [name, setName] = useState('');
const [college, setCollege] = useState('');
const [programme, setProgramme] = useState('');
const [semester, setSemester] = useState('');
const [minutes, setMinutes] = useState('60');
const [recessEnabled, setRecessEnabled] = useState(true);
const [recessStart, setRecessStart] = useState('13:00');
const [recessEnd, setRecessEnd] = useState('14:00');
const [weekend, setWeekend] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState('');
const [formError, setFormError] = useState('');
const [saved, setSaved] = useState(false);
const load = useCallback(() => {
setLoading(true);
setLoadError('');
collegeApi.profile()
.then((profile) => {
setName(profile.name);
setCollege(profile.college);
setProgramme(profile.programme);
setSemester(profile.semester);
setMinutes(String(profile.lectureMinutes ?? 60));
setRecessEnabled(Boolean(profile.recessEnabled));
setRecessStart(profile.recessStart ?? '13:00');
setRecessEnd(profile.recessEnd ?? '14:00');
setWeekend(Boolean(profile.weekendSchedule));
})
.catch((error: Error) => setLoadError(error.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
const duration = Number(minutes);
if (!name.trim() || !college.trim() || !Number.isInteger(duration) || duration < 15 || duration > 360 || !/^\d{2}:\d{2}$/.test(recessStart) || !/^\d{2}:\d{2}$/.test(recessEnd) || (recessEnabled && recessStart >= recessEnd)) {
setFormError('Add your name and college, then use a valid class length and recess time range.');
return;
}
setSaving(true);
setFormError('');
setSaved(false);
try {
await Promise.all([
collegeApi.updateProfile({ name, college, programme, semester }),
collegeApi.updateSettings({ lectureMinutes: duration, recessEnabled, recessStart, recessEnd, weekendSchedule: weekend }),
]);
setSaved(true);
} catch (error) {
setFormError(error instanceof Error ? error.message : 'Please try again.');
} finally {
setSaving(false);
}
};
return <Screen contentContainerStyle={styles.content}>
<AppHeader title="Settings" />
{loading ? <View style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading settings</AppText></View> : null}
{loadError ? <InlineBanner title="Couldnt load settings" message={loadError} tone="danger" action={<Button label="Retry" variant="ghost" size="compact" fullWidth={false} onPress={load} />} style={styles.firstBlock} /> : null}
{!loading && !loadError ? <>
<Card tone="sky" style={styles.intro}>
<View style={styles.introIcon}><Ionicons name="time-outline" size={24} color={colors.brand.cobalt} /></View>
<View style={styles.introCopy}>
<AppText variant="title">Shape your class day</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.introText}>These defaults make adding classes faster. You can still adjust individual times.</AppText>
</View>
</Card>
{saved ? <InlineBanner title="Settings saved" message="New classes will use these defaults." tone="success" style={styles.feedback} /> : null}
{formError ? <InlineBanner title="Check your settings" message={formError} tone="danger" style={styles.feedback} /> : null}
<AppText variant="heading3" style={styles.sectionTitle}>Profile</AppText>
<Card style={styles.card}>
<View style={styles.fields}>
<FormField label="Your name" value={name} onChangeText={(value) => { setName(value); setSaved(false); }} placeholder="Your name" autoCapitalize="words" />
<FormField label="College or university" value={college} onChangeText={(value) => { setCollege(value); setSaved(false); }} placeholder="College or university" autoCapitalize="words" />
<FormField label="Programme (optional)" value={programme} onChangeText={(value) => { setProgramme(value); setSaved(false); }} placeholder="e.g. B.Tech Computer Science" autoCapitalize="words" />
<FormField label="Semester (optional)" value={semester} onChangeText={(value) => { setSemester(value); setSaved(false); }} placeholder="e.g. Semester 3" autoCapitalize="words" />
</View>
</Card>
<AppText variant="heading3" style={styles.sectionTitle}>Class timing</AppText>
<Card style={styles.card}>
<View style={styles.settingHeading}>
<View style={styles.settingIcon}><Ionicons name="hourglass-outline" size={20} color={colors.brand.cobalt} /></View>
<View style={styles.settingCopy}>
<AppText variant="title">Default class length</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.settingSub}>Used to suggest an end time.</AppText>
</View>
</View>
<FormField label="Minutes" value={minutes} onChangeText={(value) => { setMinutes(value); setSaved(false); }} keyboardType="number-pad" placeholder="60" hint="Between 15 and 360 minutes." containerStyle={styles.nestedField} />
<View style={styles.divider} />
<View style={styles.switchRow}>
<View style={styles.settingIcon}><Ionicons name="cafe-outline" size={20} color={colors.brand.cobalt} /></View>
<View style={styles.settingCopy}>
<AppText variant="title">Recess or lunch</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.settingSub}>Show this break in your daily timeline.</AppText>
</View>
<Switch
accessibilityLabel="Recess or lunch"
value={recessEnabled}
onValueChange={(value) => { setRecessEnabled(value); setSaved(false); }}
trackColor={{ false: colors.neutral.border, true: colors.brand.cobaltSoft }}
thumbColor={recessEnabled ? colors.brand.cobalt : colors.neutral.surface}
/>
</View>
<View style={styles.timeFields}>
<FormField editable={recessEnabled} label="Starts" value={recessStart} onChangeText={(value) => { setRecessStart(value); setSaved(false); }} placeholder="13:00" hint="HH:MM" containerStyle={styles.timeField} />
<FormField editable={recessEnabled} label="Ends" value={recessEnd} onChangeText={(value) => { setRecessEnd(value); setSaved(false); }} placeholder="14:00" hint="HH:MM" containerStyle={styles.timeField} />
</View>
</Card>
<AppText variant="heading3" style={styles.sectionTitle}>Schedule</AppText>
<Card style={styles.card}>
<View style={styles.switchRow}>
<View style={styles.settingIcon}><Ionicons name="calendar-outline" size={20} color={colors.brand.cobalt} /></View>
<View style={styles.settingCopy}>
<AppText variant="title">Weekend schedule</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.settingSub}>Show Saturday and Sunday in week selectors.</AppText>
</View>
<Switch
accessibilityLabel="Weekend schedule"
value={weekend}
onValueChange={(value) => { setWeekend(value); setSaved(false); }}
trackColor={{ false: colors.neutral.border, true: colors.brand.cobaltSoft }}
thumbColor={weekend ? colors.brand.cobalt : colors.neutral.surface}
/>
</View>
</Card>
<Button label="Save settings" loading={saving} haptic="success" onPress={save} style={styles.save} />
</> : null}
</Screen>;
}
const styles = StyleSheet.create({
content: { paddingBottom: spacing[9] },
loading: { minHeight: 240, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
firstBlock: { marginTop: spacing[6] },
intro: { marginTop: spacing[5], flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
introIcon: { width: 48, height: 48, borderRadius: radius.card, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
introCopy: { flex: 1 },
introText: { marginTop: spacing[1] },
feedback: { marginTop: spacing[4] },
sectionTitle: { marginTop: spacing[8], marginBottom: spacing[3] },
card: { padding: spacing[5] },
fields: { gap: spacing[4] },
settingHeading: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
settingIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
settingCopy: { flex: 1 },
settingSub: { marginTop: spacing[1] },
nestedField: { marginTop: spacing[5], marginLeft: 52 },
divider: { height: 1, backgroundColor: colors.neutral.divider, marginVertical: spacing[6] },
timeFields: { marginTop: spacing[5], marginLeft: 52, flexDirection: 'row', gap: spacing[3] },
timeField: { flex: 1 },
switchRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
save: { marginTop: spacing[7] },
});

213
app/subjects/[id].tsx Normal file
View File

@@ -0,0 +1,213 @@
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import {
AppHeader,
AppText,
Button,
Card,
EmptyState,
FormField,
IconButton,
InlineBanner,
ProgressBar,
Screen,
StatusPill,
SubjectBadge,
colors,
radius,
spacing,
type SemanticTone,
} from '@/components/ui';
import { formatSessionDate } from '@/lib/date';
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
import { collegeApi, type AttendanceStatus } from '@/lib/api';
type Details = Awaited<ReturnType<typeof collegeApi.subject>>;
const initials = (value: string) => value.trim().split(/\s+/).filter(Boolean).map((word) => word[0]).join('').toUpperCase().slice(0, 6);
const statusConfig: Record<AttendanceStatus, { label: string; tone: SemanticTone; icon: keyof typeof Ionicons.glyphMap }> = {
pending: { label: 'Not marked', tone: 'warning', icon: 'time' },
attended: { label: 'Attended', tone: 'success', icon: 'checkmark-circle' },
absent: { label: 'Absent', tone: 'danger', icon: 'close-circle' },
cancelled: { label: 'Cancelled', tone: 'neutral', icon: 'remove-circle' },
};
export default function SubjectDetailsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const subjectId = Number(id);
const [data, setData] = useState<Details | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [editing, setEditing] = useState(false);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [shortName, setShortName] = useState('');
const [classType, setClassType] = useState('Lecture');
const [defaultRoom, setDefaultRoom] = useState('');
const [shortEdited, setShortEdited] = useState(false);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const load = useCallback(() => {
if (!Number.isFinite(subjectId)) return;
setLoading(true);
setLoadError('');
collegeApi.subject(subjectId)
.then((subject) => {
setData(subject);
setName(subject.name);
setCode(subject.code);
setShortName(subject.shortName || initials(subject.name));
setClassType(subject.classType || 'Lecture');
setDefaultRoom(subject.defaultRoom || '');
setShortEdited(Boolean(subject.shortName));
})
.catch((error: Error) => setLoadError(error.message))
.finally(() => setLoading(false));
}, [subjectId]);
useEffect(() => { load(); }, [load]);
const changeName = (value: string) => {
setName(value);
if (!shortEdited) setShortName(initials(value));
};
const save = async () => {
if (!name.trim() || !code.trim() || !shortName.trim() || !classType.trim()) {
setFormError('Name, subject code, short name, and class type are required.');
return;
}
setSaving(true);
setFormError('');
try {
const tone = subjectToneFor(subjectId, data?.color);
await collegeApi.updateSubject(subjectId, { name, code, shortName, classType, defaultRoom, color: colors.subject[tone].accent });
setEditing(false);
load();
} catch (error) {
setFormError(error instanceof Error ? error.message : 'Please try again.');
} finally {
setSaving(false);
}
};
if (loading && !data) return <Screen scroll={false} contentContainerStyle={styles.center}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.loadingText}>Loading subject</AppText></Screen>;
if (!data) return <Screen contentContainerStyle={styles.content}>
<AppHeader compact title="Subject details" leading={<IconButton icon="chevron-back" label="Go back" onPress={() => router.back()} />} />
<InlineBanner title="Couldnt load subject" message={loadError || 'This subject is unavailable.'} tone="danger" action={<Button label="Retry" variant="ghost" size="compact" fullWidth={false} onPress={load} />} style={styles.error} />
</Screen>;
const tone = subjectToneFor(data.id, data.color);
const palette = colors.subject[tone];
const summaryTone = attendanceTone(data.summary.percentage, data.summary.total);
const summaryCopy = attendanceMessage(data.summary.percentage, data.summary.total);
return <Screen contentContainerStyle={styles.content}>
<AppHeader
compact
title="Subject details"
leading={<IconButton icon="chevron-back" label="Go back" onPress={() => router.back()} />}
trailing={<IconButton icon={editing ? 'close' : 'create-outline'} label={editing ? 'Cancel editing' : 'Edit subject'} onPress={() => { setEditing((value) => !value); setFormError(''); }} />}
/>
<View style={[styles.hero, { backgroundColor: palette.surface }]}>
<View style={styles.heroIdentity}>
<SubjectBadge shortName={data.shortName || initials(data.name)} tone={tone} style={styles.badge} />
<View style={styles.heroCopy}>
<AppText variant="heading2" numberOfLines={2}>{data.name}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.heroMeta}>{data.code} · {data.shortName || initials(data.name)}</AppText>
</View>
</View>
<View style={styles.heroProgress}>
<View>
<AppText variant="heading1" style={styles.tabular}>{data.summary.total ? `${data.summary.percentage}%` : '—'}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted}>Attendance</AppText>
</View>
<View style={styles.progressWrap}><ProgressBar value={data.summary.total ? data.summary.percentage : 0} color={palette.accent} accessibilityLabel={`${data.name} attendance`} /></View>
</View>
</View>
{editing ? <Card style={styles.editCard}>
<AppText variant="heading3">Edit subject</AppText>
{formError ? <InlineBanner title="Check the subject details" message={formError} tone="danger" style={styles.formError} /> : null}
<View style={styles.fields}>
<FormField label="Subject name" value={name} onChangeText={changeName} placeholder="e.g. Data Structures" autoCapitalize="words" />
<FormField label="Subject code" value={code} onChangeText={(value) => setCode(value.toUpperCase())} placeholder="e.g. CS201" autoCapitalize="characters" />
<FormField label="Short name" value={shortName} onChangeText={(value) => { setShortEdited(true); setShortName(value.toUpperCase().slice(0, 6)); }} placeholder="e.g. DS" hint="Up to 6 characters; used in compact schedule views." autoCapitalize="characters" />
<FormField label="Class type" value={classType} onChangeText={setClassType} placeholder="e.g. Theory, Lab, Workshop" hint="Shown on each class card." autoCapitalize="words" />
<FormField label="Default room or location" value={defaultRoom} onChangeText={setDefaultRoom} placeholder="e.g. B-204 or Lab 3" hint="Used automatically when you add a class." autoCapitalize="words" />
</View>
<Button label="Save changes" loading={saving} haptic="success" onPress={save} />
</Card> : <>
<InlineBanner title={summaryCopy.title} message={summaryCopy.message} tone={summaryTone} style={styles.insight} />
<Card padding={0} style={styles.stats}>
<Stat value={data.summary.total} label="Classes held" />
<View style={styles.statDivider} />
<Stat value={data.summary.attended} label="Attended" />
<View style={styles.statDivider} />
<Stat value={data.summary.absent} label="Missed" />
</Card>
<View style={styles.sectionHeader}>
<AppText variant="heading2">Recent classes</AppText>
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>Attendance history for this subject</AppText>
</View>
{data.sessions.length === 0 ? <EmptyState icon="calendar-outline" title="No classes yet" message="Classes for this subject will appear here once they are scheduled." /> : <Card padding={0} style={styles.history}>
{data.sessions.map((session, index) => {
const status = statusConfig[session.status];
return <View key={session.id} style={styles.session}>
<View style={[styles.statusDot, { backgroundColor: colors.semantic[status.tone].solid }]} />
<View style={styles.sessionCopy}>
<AppText variant="label">{formatSessionDate(session.date)}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.sessionMeta}>{session.time}{session.endTime} · {session.room}</AppText>
</View>
<StatusPill label={status.label} tone={status.tone} icon={status.icon} />
{index < data.sessions.length - 1 ? <View style={styles.divider} /> : null}
</View>;
})}
</Card>}
</>}
</Screen>;
}
function Stat({ value, label }: { value: number; label: string }) {
return <View style={styles.stat}>
<AppText variant="heading3" style={styles.tabular}>{value}</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.statLabel}>{label}</AppText>
</View>;
}
const styles = StyleSheet.create({
content: { paddingBottom: spacing[9] },
center: { alignItems: 'center', justifyContent: 'center' },
loadingText: { marginTop: spacing[3] },
error: { marginTop: spacing[6] },
hero: { marginTop: spacing[5], borderRadius: radius.feature, padding: spacing[6] },
heroIdentity: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
badge: { width: 52, height: 52, borderRadius: 17 },
heroCopy: { flex: 1 },
heroMeta: { marginTop: spacing[1] },
heroProgress: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'flex-end', gap: spacing[5] },
progressWrap: { flex: 1, paddingBottom: spacing[2] },
tabular: { fontVariant: ['tabular-nums'] },
editCard: { marginTop: spacing[4] },
formError: { marginTop: spacing[4] },
fields: { gap: spacing[4], marginVertical: spacing[5] },
insight: { marginTop: spacing[4] },
stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
statDivider: { width: 1, height: 36, backgroundColor: colors.neutral.divider },
statLabel: { marginTop: spacing[1], textAlign: 'center' },
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4] },
sectionSub: { marginTop: spacing[1] },
history: { overflow: 'hidden' },
session: { minHeight: 76, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[4], paddingVertical: spacing[3] },
statusDot: { width: 9, height: 9, borderRadius: 5 },
sessionCopy: { flex: 1 },
sessionMeta: { marginTop: spacing[1], fontVariant: ['tabular-nums'] },
divider: { position: 'absolute', left: 36, right: spacing[4], bottom: 0, height: 1, backgroundColor: colors.neutral.divider },
});