Update app and ignore local artifacts
This commit is contained in:
@@ -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
163
app/(tabs)/attendance.tsx
Normal 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="Couldn’t 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 },
|
||||
});
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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('Couldn’t 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('Couldn’t 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 ? 'Today’s 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="Couldn’t 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
1
app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../settings';
|
||||
161
app/(tabs)/timetable.tsx
Normal file
161
app/(tabs)/timetable.tsx
Normal 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="Couldn’t 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 day’s 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] },
|
||||
});
|
||||
Reference in New Issue
Block a user