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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.expo
dist
web-build
.git
.gitignore
.vscode
.claude
backend/data
*.log
npm-debug.log
README.md

8
.gitignore vendored
View File

@@ -25,13 +25,16 @@ expo-env.d.ts
npm-debug.*
yarn-debug.*
yarn-error.*
debug_screenshots/
# macOS
.DS_Store
*.pem
# local env files
.env*.local
.env
.env.*
!.env.example
# typescript
*.tsbuildinfo
@@ -41,3 +44,6 @@ app-example
# generated native folders
/ios
/android
# Local College Kit API database
backend/data/

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# Build the Expo Router web bundle. Expo replaces EXPO_PUBLIC_* values at export time.
FROM node:22-bookworm-slim AS web-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG EXPO_PUBLIC_API_URL=/api/v1
ENV EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL
RUN npx expo export --platform web
# Serve the static bundle and proxy same-origin API traffic.
FROM nginx:1.27-alpine AS web
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-build /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,50 +1,44 @@
# Welcome to your Expo app 👋
# College Kit
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
A personal college organiser for timetables, flexible daily classes, and attendance tracking.
## Get started
## Run with Docker
1. Install dependencies
The complete production-style stack consists of an Expo Router web bundle served by Nginx and a Node + SQLite API. Nginx proxies the API at the same origin, so no browser CORS or API URL configuration is needed.
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```sh
docker compose up --build
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
Open [http://localhost:8080](http://localhost:8080). The API is available through the web container at `http://localhost:8080/api/v1/*`, and its health check is at `http://localhost:8080/health`.
## Learn more
Stop the stack without deleting attendance data:
To learn more about developing your project with Expo, look at the following resources:
```sh
docker compose down
```
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
The SQLite database persists in the named `college-kit-data` Docker volume. To completely reset the seeded application data:
## Join the community
```sh
docker compose down -v
```
Join our community of developers creating universal apps.
## Local development
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
Start the API and Expo development server in separate terminals:
```sh
npm run api
npm start
```
The local web app uses `http://localhost:4000/api/v1` by default. To use a physical device, point Expo at your computer's LAN IP:
```sh
EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1 npm start
```
## Backend
The API and its data model are documented in [`backend/README.md`](backend/README.md). It provides persistent recurring timetable rules, dated class overrides, attendance statuses, subjects, and attendance summaries.

View File

@@ -6,7 +6,7 @@
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "clgkit",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
@@ -33,10 +33,7 @@
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
"backgroundColor": "#F7F9FC"
}
]
],

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 },
});

9
backend/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY backend/server.mjs ./backend/server.mjs
RUN mkdir -p /app/backend/data && chown -R node:node /app
USER node
ENV NODE_ENV=production
ENV PORT=4000
EXPOSE 4000
CMD ["node", "backend/server.mjs"]

50
backend/README.md Normal file
View File

@@ -0,0 +1,50 @@
# College Kit API
A zero-dependency Node HTTP API backed by a persistent SQLite database. The database is created at `backend/data/college-kit.db` on first start with no subjects, timetable rules, sessions, or attendance records.
## Run
```sh
npm run api
```
The API listens on `http://localhost:4000`. In a second terminal, run the Expo app:
```sh
npm start
```
For a physical device, expose the API on your development machine's LAN IP:
```sh
EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1 npm start
```
## Data model
- `users` — currently a seeded local user; this is the seam for authentication.
- `subjects` — course name, code, and display colour.
- `timetable_classes` — recurring weekly timetable rules.
- `class_sessions` — dated generated classes plus one-off overrides.
- `attendance` — one status per session: `pending`, `attended`, `absent`, or `cancelled`.
Sessions are generated from the weekly timetable when a date is requested. One-off sessions and edits are stored independently, so daily changes never alter the regular timetable. Cancelled sessions are excluded from attendance totals.
Recurring timetable edits are **versioned**: `PATCH /timetable/classes/:id` closes the existing rule and creates a replacement rule effective tomorrow (or a later supplied date). It only removes future, auto-generated sessions for the old rule. Past sessions, their times, and every attendance record are retained unchanged.
## API
| Method | Route | Purpose |
| --- | --- | --- |
| GET | `/health` | Service check |
| GET | `/api/v1/profile` | Current user profile |
| GET | `/api/v1/schedule?date=YYYY-MM-DD` | Get/create dated sessions |
| POST | `/api/v1/schedule` | Add a one-off class |
| PATCH / DELETE | `/api/v1/schedule/:id` | Edit/remove a dated class |
| PUT | `/api/v1/schedule/:id/attendance` | Set `{ "status": "attended" }` |
| GET / POST | `/api/v1/subjects` | List/create subjects |
| GET / POST | `/api/v1/timetable/classes?date=YYYY-MM-DD` | List/create recurring classes active for a date |
| PATCH | `/api/v1/timetable/classes/:id` | Version a recurring class change for tomorrow or later |
| GET | `/api/v1/attendance/summary` | Per-subject attendance totals |
Timetable image OCR is deliberately not performed by this service. A mobile client should upload/select an image, run OCR with a provider of choice, let the student review the extracted classes, then send confirmed subjects and timetable classes through the endpoints above. This avoids silently creating an inaccurate timetable from an image.

250
backend/server.mjs Normal file
View File

@@ -0,0 +1,250 @@
import { createServer } from 'node:http';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, 'data');
mkdirSync(dataDir, { recursive: true });
const db = new DatabaseSync(join(dataDir, 'college-kit.db'));
db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
const PORT = Number(process.env.PORT || 4000);
const STUDENT_ID = 1; // Replace with the authenticated user's id when auth is added.
const validStatuses = new Set(['attended', 'absent', 'cancelled', 'pending']);
function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, initials TEXT NOT NULL,
college TEXT NOT NULL DEFAULT '', programme TEXT NOT NULL DEFAULT '', semester TEXT NOT NULL DEFAULT '',
lecture_minutes INTEGER NOT NULL DEFAULT 60, recess_enabled INTEGER NOT NULL DEFAULT 1, recess_start TEXT NOT NULL DEFAULT '13:00', recess_end TEXT NOT NULL DEFAULT '14:00', weekend_schedule INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL, code TEXT NOT NULL, short_name TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '#0559FA',
class_type TEXT NOT NULL DEFAULT 'Lecture', default_room TEXT NOT NULL DEFAULT '',
UNIQUE(user_id, code)
);
CREATE TABLE IF NOT EXISTS timetable_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
subject_id INTEGER NOT NULL REFERENCES subjects(id), weekday INTEGER NOT NULL CHECK(weekday BETWEEN 0 AND 6),
start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
effective_from TEXT NOT NULL DEFAULT '1970-01-01', effective_to TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS class_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id),
subject_id INTEGER NOT NULL REFERENCES subjects(id), timetable_class_id INTEGER REFERENCES timetable_classes(id),
class_date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, room TEXT NOT NULL,
is_override INTEGER NOT NULL DEFAULT 0, is_removed INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, timetable_class_id, class_date)
);
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL UNIQUE REFERENCES class_sessions(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK(status IN ('pending', 'attended', 'absent', 'cancelled')) DEFAULT 'pending',
marked_at TEXT, note TEXT
);
`);
const subjectColumns = db.prepare('PRAGMA table_info(subjects)').all().map((column) => column.name);
if (!subjectColumns.includes('short_name')) db.exec("ALTER TABLE subjects ADD COLUMN short_name TEXT NOT NULL DEFAULT ''");
if (!subjectColumns.includes('class_type')) db.exec("ALTER TABLE subjects ADD COLUMN class_type TEXT NOT NULL DEFAULT 'Lecture'");
if (!subjectColumns.includes('default_room')) db.exec("ALTER TABLE subjects ADD COLUMN default_room TEXT NOT NULL DEFAULT ''");
const userColumns = db.prepare('PRAGMA table_info(users)').all().map((column) => column.name);
if (!userColumns.includes('college')) db.exec("ALTER TABLE users ADD COLUMN college TEXT NOT NULL DEFAULT ''");
if (!userColumns.includes('programme')) db.exec("ALTER TABLE users ADD COLUMN programme TEXT NOT NULL DEFAULT ''");
if (!userColumns.includes('semester')) db.exec("ALTER TABLE users ADD COLUMN semester TEXT NOT NULL DEFAULT ''");
if (!userColumns.includes('lecture_minutes')) db.exec('ALTER TABLE users ADD COLUMN lecture_minutes INTEGER NOT NULL DEFAULT 60');
if (!userColumns.includes('recess_enabled')) db.exec('ALTER TABLE users ADD COLUMN recess_enabled INTEGER NOT NULL DEFAULT 1');
if (!userColumns.includes('recess_start')) db.exec("ALTER TABLE users ADD COLUMN recess_start TEXT NOT NULL DEFAULT '13:00'");
if (!userColumns.includes('recess_end')) db.exec("ALTER TABLE users ADD COLUMN recess_end TEXT NOT NULL DEFAULT '14:00'");
if (!userColumns.includes('weekend_schedule')) db.exec('ALTER TABLE users ADD COLUMN weekend_schedule INTEGER NOT NULL DEFAULT 0');
const sessionColumns = db.prepare('PRAGMA table_info(class_sessions)').all().map((column) => column.name);
if (!sessionColumns.includes('is_removed')) db.exec('ALTER TABLE class_sessions ADD COLUMN is_removed INTEGER NOT NULL DEFAULT 0');
const templateColumns = db.prepare('PRAGMA table_info(timetable_classes)').all().map((column) => column.name);
if (!templateColumns.includes('effective_from')) db.exec("ALTER TABLE timetable_classes ADD COLUMN effective_from TEXT NOT NULL DEFAULT '1970-01-01'");
if (!templateColumns.includes('effective_to')) db.exec('ALTER TABLE timetable_classes ADD COLUMN effective_to TEXT');
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(STUDENT_ID);
// Keep only an empty local account record for the unauthenticated prototype.
// Courses, timetable rules, sessions, and attendance are never seeded.
if (!user) db.prepare('INSERT INTO users (id, name, initials) VALUES (?, ?, ?)').run(STUDENT_ID, '', '');
}
function json(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS' });
res.end(JSON.stringify(body));
}
function error(res, status, message) { json(res, status, { error: message }); }
function dateIsValid(value) { return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(`${value}T12:00:00`).valueOf()); }
function weekdayFor(date) { return new Date(`${date}T12:00:00`).getDay(); }
function localDateKey(date = new Date()) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; }
function nextDateKey(date) { const next = new Date(`${date}T12:00:00`); next.setDate(next.getDate() + 1); return localDateKey(next); }
function previousDateKey(date) { const previous = new Date(`${date}T12:00:00`); previous.setDate(previous.getDate() - 1); return localDateKey(previous); }
function readBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1_000_000) reject(new Error('Request body is too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('Body must be valid JSON')); } }); req.on('error', reject); }); }
function sessionRows(date) {
return db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room, cs.is_override AS isOverride,
s.id AS subjectId, s.name AS title, s.code, s.color, s.class_type AS classType, COALESCE(a.status, 'pending') AS status, a.note
FROM class_sessions cs JOIN subjects s ON s.id = cs.subject_id LEFT JOIN attendance a ON a.session_id = cs.id
WHERE cs.user_id = ? AND cs.class_date = ? AND cs.is_removed = 0 ORDER BY cs.start_time`).all(STUDENT_ID, date);
}
function ensureSessions(date) {
const templates = db.prepare(`SELECT * FROM timetable_classes
WHERE user_id = ? AND weekday = ? AND active = 1 AND effective_from <= ?
AND (effective_to IS NULL OR effective_to >= ?)`).all(STUDENT_ID, weekdayFor(date), date, date);
const create = db.prepare('INSERT OR IGNORE INTO class_sessions (user_id, subject_id, timetable_class_id, class_date, start_time, end_time, room) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const item of templates) create.run(STUDENT_ID, item.subject_id, item.id, date, item.start_time, item.end_time, item.room);
}
function getSubject(id) { return db.prepare('SELECT id FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID); }
migrate();
const server = createServer(async (req, res) => {
if (req.method === 'OPTIONS') return json(res, 204, {});
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const path = url.pathname;
try {
if (req.method === 'GET' && path === '/health') return json(res, 200, { ok: true });
if (req.method === 'GET' && path === '/api/v1/profile') return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
if (req.method === 'PATCH' && path === '/api/v1/settings') {
const body = await readBody(req); const minutes = Number(body.lectureMinutes); const recessEnabled = body.recessEnabled === false ? 0 : 1; const weekendSchedule = body.weekendSchedule ? 1 : 0;
const validRecess = /^\d\d:\d\d$/.test(body.recessStart) && /^\d\d:\d\d$/.test(body.recessEnd) && (!recessEnabled || body.recessStart < body.recessEnd);
if (!Number.isInteger(minutes) || minutes < 15 || minutes > 360 || !validRecess) return error(res, 400, 'Use a lecture length between 15 and 360 minutes and valid recess times');
db.prepare('UPDATE users SET lecture_minutes = ?, recess_enabled = ?, recess_start = ?, recess_end = ?, weekend_schedule = ? WHERE id = ?').run(minutes, recessEnabled, body.recessStart, body.recessEnd, weekendSchedule, STUDENT_ID);
return json(res, 200, { lectureMinutes: minutes, recessEnabled: Boolean(recessEnabled), recessStart: body.recessStart, recessEnd: body.recessEnd, weekendSchedule: Boolean(weekendSchedule) });
}
if (req.method === 'PUT' && path === '/api/v1/profile') {
const body = await readBody(req);
if (!body.name?.trim() || !body.college?.trim()) return error(res, 400, 'name and college are required');
const initials = body.name.trim().split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
db.prepare('UPDATE users SET name = ?, initials = ?, college = ?, programme = ?, semester = ? WHERE id = ?').run(body.name.trim(), initials, body.college.trim(), body.programme?.trim() || '', body.semester?.trim() || '', STUDENT_ID);
return json(res, 200, db.prepare('SELECT id, name, initials, college, programme, semester, lecture_minutes AS lectureMinutes, recess_enabled AS recessEnabled, recess_start AS recessStart, recess_end AS recessEnd, weekend_schedule AS weekendSchedule FROM users WHERE id = ?').get(STUDENT_ID));
}
if (req.method === 'GET' && path === '/api/v1/schedule') {
const date = url.searchParams.get('date');
if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
ensureSessions(date);
return json(res, 200, { date, sessions: sessionRows(date) });
}
if (req.method === 'POST' && path === '/api/v1/schedule') {
const body = await readBody(req);
const { subjectId, date, startTime, endTime, room } = body;
if (!getSubject(subjectId) || !dateIsValid(date) || !/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime) || !room?.trim()) return error(res, 400, 'subjectId, date, startTime, endTime and room are required');
const record = db.prepare('INSERT INTO class_sessions (user_id, subject_id, class_date, start_time, end_time, room, is_override) VALUES (?, ?, ?, ?, ?, ?, 1)').run(STUDENT_ID, subjectId, date, startTime, endTime, room.trim());
return json(res, 201, db.prepare('SELECT id FROM class_sessions WHERE id = ?').get(record.lastInsertRowid));
}
const scheduleMatch = path.match(/^\/api\/v1\/schedule\/(\d+)$/);
if (req.method === 'DELETE' && scheduleMatch) {
const id = Number(scheduleMatch[1]);
const session = db.prepare('SELECT timetable_class_id AS timetableClassId FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
if (!session) return error(res, 404, 'Class session not found');
// Recurring classes are hidden only for this date, so the weekly rule remains intact.
if (session.timetableClassId) db.prepare('UPDATE class_sessions SET is_removed = 1, is_override = 1 WHERE id = ?').run(id);
else db.prepare('DELETE FROM class_sessions WHERE id = ? AND user_id = ?').run(id, STUDENT_ID);
return json(res, 200, { id, deleted: true });
}
if (req.method === 'PATCH' && scheduleMatch) {
const body = await readBody(req); const id = Number(scheduleMatch[1]);
const existing = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
if (!existing) return error(res, 404, 'Class session not found');
const fields = []; const values = [];
for (const [key, column] of Object.entries({ startTime: 'start_time', endTime: 'end_time', room: 'room' })) if (body[key] !== undefined) { fields.push(`${column} = ?`); values.push(typeof body[key] === 'string' ? body[key].trim() : body[key]); }
if (!fields.length) return error(res, 400, 'No editable fields supplied');
db.prepare(`UPDATE class_sessions SET ${fields.join(', ')}, is_override = 1 WHERE id = ?`).run(...values, id);
return json(res, 200, { id });
}
const attendanceMatch = path.match(/^\/api\/v1\/schedule\/(\d+)\/attendance$/);
if (req.method === 'PUT' && attendanceMatch) {
const body = await readBody(req); const id = Number(attendanceMatch[1]);
if (!validStatuses.has(body.status)) return error(res, 400, 'status must be pending, attended, absent, or cancelled');
const session = db.prepare('SELECT id FROM class_sessions WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
if (!session) return error(res, 404, 'Class session not found');
db.prepare(`INSERT INTO attendance (session_id, status, marked_at, note) VALUES (?, ?, CURRENT_TIMESTAMP, ?)
ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, marked_at = CURRENT_TIMESTAMP, note = excluded.note`).run(id, body.status, body.note?.trim() || null);
return json(res, 200, { id, status: body.status });
}
if (req.method === 'GET' && path === '/api/v1/subjects') {
return json(res, 200, db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE user_id = ? ORDER BY name').all(STUDENT_ID));
}
if (req.method === 'POST' && path === '/api/v1/subjects') {
const body = await readBody(req);
if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
try {
const result = db.prepare('INSERT INTO subjects (user_id, name, code, short_name, color, class_type, default_room) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '');
return json(res, 201, { id: Number(result.lastInsertRowid) });
} catch { return error(res, 409, 'A subject with that code already exists'); }
}
if (req.method === 'GET' && path === '/api/v1/timetable/classes') {
const date = url.searchParams.get('date') || localDateKey();
if (!dateIsValid(date)) return error(res, 400, 'date must be YYYY-MM-DD');
const rows = db.prepare(`SELECT tc.id, tc.weekday, tc.start_time AS startTime, tc.end_time AS endTime, tc.room, tc.active,
tc.effective_from AS effectiveFrom, tc.effective_to AS effectiveTo, s.id AS subjectId, s.name AS subjectName, s.code, s.color, s.class_type AS classType
FROM timetable_classes tc JOIN subjects s ON s.id = tc.subject_id
WHERE tc.user_id = ? AND tc.weekday = ? AND tc.active = 1 AND tc.effective_from <= ?
AND (tc.effective_to IS NULL OR tc.effective_to >= ?) ORDER BY tc.start_time`).all(STUDENT_ID, weekdayFor(date), date, date);
return json(res, 200, { date, classes: rows });
}
if (req.method === 'POST' && path === '/api/v1/timetable/classes') {
const body = await readBody(req);
// A newly added recurring class must also begin in the future; past timetable history is immutable.
const earliestFutureDate = nextDateKey(localDateKey());
const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > localDateKey() ? body.effectiveFrom : earliestFutureDate;
if (!getSubject(body.subjectId) || !Number.isInteger(body.weekday) || body.weekday < 0 || body.weekday > 6 || !dateIsValid(effectiveFrom) || !/^\d\d:\d\d$/.test(body.startTime) || !/^\d\d:\d\d$/.test(body.endTime) || !body.room?.trim()) return error(res, 400, 'subjectId, weekday, startTime, endTime, room and a valid effectiveFrom date are required');
const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, body.subjectId, body.weekday, body.startTime, body.endTime, body.room.trim(), effectiveFrom);
return json(res, 201, { id: Number(result.lastInsertRowid), effectiveFrom });
}
const timetableMatch = path.match(/^\/api\/v1\/timetable\/classes\/(\d+)$/);
if (req.method === 'PATCH' && timetableMatch) {
const body = await readBody(req); const id = Number(timetableMatch[1]);
const existing = db.prepare('SELECT * FROM timetable_classes WHERE id = ? AND user_id = ? AND active = 1').get(id, STUDENT_ID);
if (!existing) return error(res, 404, 'Timetable class not found');
// A recurring timetable edit is versioned. It can only start tomorrow or later,
// and past sessions are never updated or deleted.
const earliestFutureDate = nextDateKey(localDateKey());
const effectiveFrom = body.effectiveFrom && dateIsValid(body.effectiveFrom) && body.effectiveFrom > earliestFutureDate ? body.effectiveFrom : earliestFutureDate;
const nextSubjectId = body.subjectId === undefined ? existing.subject_id : body.subjectId;
const nextWeekday = body.weekday === undefined ? existing.weekday : body.weekday;
const nextStart = body.startTime === undefined ? existing.start_time : body.startTime;
const nextEnd = body.endTime === undefined ? existing.end_time : body.endTime;
const nextRoom = body.room === undefined ? existing.room : body.room.trim();
if (!getSubject(nextSubjectId) || !Number.isInteger(nextWeekday) || nextWeekday < 0 || nextWeekday > 6 || !/^\d\d:\d\d$/.test(nextStart) || !/^\d\d:\d\d$/.test(nextEnd) || !nextRoom) return error(res, 400, 'Invalid timetable change');
db.prepare('UPDATE timetable_classes SET effective_to = ? WHERE id = ?').run(previousDateKey(effectiveFrom), id);
// Remove only generated, unmodified FUTURE sessions. Historical sessions and daily overrides stay intact.
db.prepare('DELETE FROM class_sessions WHERE timetable_class_id = ? AND class_date >= ? AND is_override = 0').run(id, effectiveFrom);
const result = db.prepare('INSERT INTO timetable_classes (user_id, subject_id, weekday, start_time, end_time, room, effective_from) VALUES (?, ?, ?, ?, ?, ?, ?)').run(STUDENT_ID, nextSubjectId, nextWeekday, nextStart, nextEnd, nextRoom, effectiveFrom);
return json(res, 201, { id: Number(result.lastInsertRowid), replacesId: id, effectiveFrom });
}
const subjectMatch = path.match(/^\/api\/v1\/subjects\/(\d+)$/);
if (req.method === 'GET' && subjectMatch) {
const id = Number(subjectMatch[1]);
const subject = db.prepare('SELECT id, name, code, short_name AS shortName, color, class_type AS classType, default_room AS defaultRoom FROM subjects WHERE id = ? AND user_id = ?').get(id, STUDENT_ID);
if (!subject) return error(res, 404, 'Subject not found');
const sessions = db.prepare(`SELECT cs.id, cs.class_date AS date, cs.start_time AS time, cs.end_time AS endTime, cs.room,
COALESCE(a.status, 'pending') AS status FROM class_sessions cs LEFT JOIN attendance a ON a.session_id = cs.id
WHERE cs.subject_id = ? AND cs.user_id = ? ORDER BY cs.class_date DESC, cs.start_time DESC`).all(id, STUDENT_ID);
const eligible = sessions.filter((session) => session.status !== 'cancelled');
const attended = eligible.filter((session) => session.status === 'attended').length;
return json(res, 200, { ...subject, summary: { total: eligible.length, attended, absent: eligible.filter((session) => session.status === 'absent').length, percentage: eligible.length ? Math.round(attended / eligible.length * 100) : 0 }, sessions });
}
if (req.method === 'PATCH' && subjectMatch) {
const id = Number(subjectMatch[1]); const body = await readBody(req);
const existing = getSubject(id); if (!existing) return error(res, 404, 'Subject not found');
if (!body.name?.trim() || !body.code?.trim()) return error(res, 400, 'name and code are required');
try {
db.prepare('UPDATE subjects SET name = ?, code = ?, short_name = ?, color = ?, class_type = ?, default_room = ? WHERE id = ? AND user_id = ?').run(body.name.trim(), body.code.trim().toUpperCase(), body.shortName?.trim() || body.code.trim().toUpperCase(), body.color || '#0559FA', body.classType?.trim() || 'Lecture', body.defaultRoom?.trim() || '', id, STUDENT_ID);
return json(res, 200, { id });
} catch { return error(res, 409, 'A subject with that code already exists'); }
}
if (req.method === 'GET' && path === '/api/v1/attendance/summary') {
const rows = db.prepare(`SELECT s.id, s.name, s.code, s.color,
COUNT(cs.id) AS total, SUM(CASE WHEN a.status = 'attended' THEN 1 ELSE 0 END) AS attended,
SUM(CASE WHEN a.status = 'absent' THEN 1 ELSE 0 END) AS absent,
SUM(CASE WHEN a.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM subjects s LEFT JOIN class_sessions cs ON cs.subject_id = s.id LEFT JOIN attendance a ON a.session_id = cs.id
WHERE s.user_id = ? GROUP BY s.id ORDER BY s.name`).all(STUDENT_ID);
return json(res, 200, { subjects: rows.map(r => ({ ...r, total: Number(r.total) - Number(r.cancelled), percentage: Number(r.total) - Number(r.cancelled) ? Math.round(Number(r.attended) / (Number(r.total) - Number(r.cancelled)) * 100) : 0 })) });
}
return error(res, 404, 'Route not found');
} catch (err) { console.error(err); return error(res, 400, err.message || 'Bad request'); }
});
server.listen(PORT, '0.0.0.0', () => console.log(`College Kit API listening on http://localhost:${PORT}`));

View File

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

View File

@@ -35,26 +35,30 @@ export function ThemedText({
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
fontFamily: 'Manrope_400Regular',
fontSize: 15,
lineHeight: 22,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
fontFamily: 'Manrope_600SemiBold',
fontSize: 15,
lineHeight: 22,
},
title: {
fontFamily: 'Manrope_800ExtraBold',
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
lineHeight: 38,
letterSpacing: -0.8,
},
subtitle: {
fontFamily: 'Manrope_700Bold',
fontSize: 20,
fontWeight: 'bold',
lineHeight: 26,
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
fontFamily: 'Manrope_600SemiBold',
lineHeight: 22,
fontSize: 15,
color: '#0559FA',
},
});

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, size, spacing } from './tokens';
type Props = {
title: string;
context?: string;
subtitle?: string;
leading?: ReactNode;
trailing?: ReactNode;
compact?: boolean;
style?: StyleProp<ViewStyle>;
};
export function AppHeader({ title, context, subtitle, leading, trailing, compact = false, style }: Props) {
if (compact) return <View style={[styles.compact, style]}>
<View style={styles.side}>{leading}</View>
<AppText variant="title" numberOfLines={1} style={styles.compactTitle}>{title}</AppText>
<View style={[styles.side, styles.trailing]}>{trailing}</View>
</View>;
return <View style={[styles.root, style]}>
<View style={styles.copy}>
{context ? <AppText variant="bodySmall" color={colors.neutral.textMuted}>{context}</AppText> : null}
<AppText variant="heading1" style={context ? styles.titleWithContext : undefined}>{title}</AppText>
{subtitle ? <AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.subtitle}>{subtitle}</AppText> : null}
</View>
{trailing ? <View style={styles.rootTrailing}>{trailing}</View> : null}
</View>;
}
const styles = StyleSheet.create({
root: { minHeight: 76, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[5], paddingTop: spacing[6] },
copy: { flex: 1 },
titleWithContext: { marginTop: spacing[1] },
subtitle: { marginTop: spacing[1] },
rootTrailing: { alignSelf: 'flex-start', paddingTop: spacing[1] },
compact: { height: 56, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
side: { width: size.touchTargetMin, minHeight: size.touchTargetMin, justifyContent: 'center' },
trailing: { alignItems: 'flex-end' },
compactTitle: { flex: 1, textAlign: 'center', paddingHorizontal: spacing[3] },
});

View File

@@ -0,0 +1,12 @@
import { Text, type TextProps, type TextStyle } from 'react-native';
import { colors, type, type TypeVariant } from './tokens';
type Props = TextProps & {
variant?: TypeVariant;
color?: string;
};
/** A semantic Manrope text primitive with accessible defaults. */
export function AppText({ variant = 'body', color = colors.neutral.textPrimary, style, ...props }: Props) {
return <Text {...props} style={[type[variant], { color }, style as TextStyle]} />;
}

View File

@@ -0,0 +1,58 @@
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { AppText } from './app-text';
import { colors, type SemanticTone } from './tokens';
type Props = {
percentage: number;
tone?: Extract<SemanticTone, 'success' | 'warning' | 'danger'> | 'brand';
size?: number;
strokeWidth?: number;
label?: string;
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
/** An accessible determinate progress ring for real attendance values. */
export function AttendanceRing({ percentage, tone = 'brand', size = 112, strokeWidth = 10, label = 'Attendance', accessibilityLabel, style }: Props) {
const value = Math.max(0, Math.min(100, Math.round(percentage)));
const center = size / 2;
const ringRadius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * ringRadius;
const dashOffset = circumference * (1 - value / 100);
const accent = tone === 'brand' ? colors.brand.cobalt : colors.semantic[tone].solid;
return <View
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{ min: 0, max: 100, now: value }}
style={[styles.container, { width: size, height: size }, style]}>
<Svg width={size} height={size} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
<Circle cx={center} cy={center} r={ringRadius} stroke={colors.neutral.surfaceSubtle} strokeWidth={strokeWidth} fill="none" />
<Circle
cx={center}
cy={center}
r={ringRadius}
stroke={accent}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
rotation="-90"
origin={`${center}, ${center}`}
/>
</Svg>
<View pointerEvents="none" style={styles.copy}>
<AppText variant="heading2" style={styles.value}>{value}%</AppText>
<AppText variant="caption" color={colors.neutral.textMuted} numberOfLines={1}>{label}</AppText>
</View>
</View>;
}
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center' },
copy: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },
value: { fontVariant: ['tabular-nums'] },
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AppText } from './app-text';
import { IconButton } from './icon-button';
import { colors, radius, shadow, spacing } from './tokens';
type Props = {
visible: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
};
/** Shared modal sheet with keyboard-safe padding and a single accessible close control. */
export function BottomSheet({ visible, title, onClose, children, footer, style, testID }: Props) {
return <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.overlay}>
<Pressable accessibilityRole="button" accessibilityLabel={`Close ${title}`} style={styles.backdrop} onPress={onClose} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboard}>
<SafeAreaView edges={['bottom']} accessibilityViewIsModal style={[styles.sheet, style]} testID={testID}>
<View style={styles.handle} />
<View style={styles.header}>
<AppText variant="heading3">{title}</AppText>
<IconButton icon="close" label={`Close ${title}`} tone="ghost" onPress={onClose} />
</View>
<ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} contentContainerStyle={styles.body}>{children}</ScrollView>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</SafeAreaView>
</KeyboardAvoidingView>
</View>
</Modal>;
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: colors.neutral.scrim },
backdrop: { ...StyleSheet.absoluteFillObject },
keyboard: { width: '100%', maxHeight: '92%' },
sheet: { borderTopLeftRadius: radius.sheet, borderTopRightRadius: radius.sheet, backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[6], paddingTop: spacing[3], ...shadow.floating },
handle: { width: 36, height: 4, alignSelf: 'center', borderRadius: radius.pill, backgroundColor: colors.neutral.border },
header: { minHeight: 60, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
body: { paddingBottom: spacing[5] },
footer: { paddingTop: spacing[3], paddingBottom: spacing[3], borderTopWidth: 1, borderTopColor: colors.neutral.divider },
});

View File

@@ -0,0 +1,56 @@
import { Ionicons } from '@expo/vector-icons';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import * as Haptics from 'expo-haptics';
import { Platform, Pressable, StyleSheet, View } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, shadow, size, spacing } from './tokens';
const tabConfig = {
index: { label: 'Today', icon: 'today-outline', activeIcon: 'today' },
timetable: { label: 'Timetable', icon: 'calendar-outline', activeIcon: 'calendar' },
attendance: { label: 'Attendance', icon: 'pie-chart-outline', activeIcon: 'pie-chart' },
settings: { label: 'Settings', icon: 'settings-outline', activeIcon: 'settings' },
} as const;
/** Floating, label-forward dock that keeps the active destination prominent without covering the page in a solid bar. */
export function BottomTabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
return <View pointerEvents="box-none" style={[styles.floatingArea, { paddingBottom: Math.max(insets.bottom, spacing[4]) }]}>
<View style={styles.bar}>
{state.routes.map((route, index) => {
const focused = state.index === index;
const config = tabConfig[route.name as keyof typeof tabConfig];
if (!config) return null;
const options = descriptors[route.key].options;
const label = options.tabBarLabel === undefined || typeof options.tabBarLabel === 'string' ? options.tabBarLabel ?? config.label : config.label;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) {
if (Platform.OS !== 'web') void Haptics.selectionAsync();
navigation.navigate(route.name);
}
};
const onLongPress = () => navigation.emit({ type: 'tabLongPress', target: route.key });
return <Pressable
key={route.key}
accessibilityRole="button"
accessibilityLabel={typeof label === 'string' ? label : config.label}
accessibilityState={{ selected: focused }}
onPress={onPress}
onLongPress={onLongPress}
style={({ pressed }) => [styles.tab, focused && styles.tabActive, pressed && styles.pressed]}>
<Ionicons name={focused ? config.activeIcon : config.icon} size={focused ? 19 : 20} color={focused ? colors.neutral.surface : colors.neutral.textSecondary} />
<AppText variant="caption" color={focused ? colors.neutral.surface : colors.neutral.textSecondary} numberOfLines={1} style={styles.label}>{label}</AppText>
</Pressable>;
})}
</View>
</View>;
}
const styles = StyleSheet.create({
floatingArea: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: spacing[5], paddingTop: spacing[3], backgroundColor: 'transparent' },
bar: { minHeight: size.touchTargetMin + spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[1], padding: spacing[2], borderRadius: radius.sheet, borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, ...shadow.floating },
tab: { minHeight: size.touchTargetMin, flex: 1, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, borderRadius: radius.pill, paddingHorizontal: spacing[2] },
tabActive: { flexDirection: 'row', flexGrow: 1.28, gap: spacing[2], backgroundColor: colors.brand.cobalt, paddingHorizontal: spacing[4] },
label: { textAlign: 'center' },
pressed: { opacity: 0.76 },
});

65
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,65 @@
import type { ReactNode } from 'react';
import * as Haptics from 'expo-haptics';
import { ActivityIndicator, Platform, Pressable, StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, motion, size, spacing } from './tokens';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'regular' | 'compact';
type HapticFeedback = 'none' | 'selection' | 'success' | 'light';
type Props = {
label: string;
onPress: (event: GestureResponderEvent) => void;
variant?: ButtonVariant;
size?: ButtonSize;
leading?: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
haptic?: HapticFeedback;
accessibilityHint?: string;
style?: StyleProp<ViewStyle>;
testID?: string;
};
const feedback = (type: HapticFeedback) => {
if (type === 'none' || Platform.OS === 'web') return;
if (type === 'selection') void Haptics.selectionAsync();
if (type === 'success') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
if (type === 'light') void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
export function Button({ label, onPress, variant = 'primary', size: buttonSize = 'regular', leading, trailing, disabled = false, loading = false, fullWidth = true, haptic = 'none', accessibilityHint, style, testID }: Props) {
const unavailable = disabled || loading;
const activeLabelColor = variant === 'primary' || variant === 'danger' ? colors.neutral.surface : colors.brand.cobalt;
const labelColor = unavailable ? colors.neutral.textDisabled : activeLabelColor;
const spinnerColor = unavailable ? colors.neutral.textDisabled : activeLabelColor;
return <Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
accessibilityState={{ disabled: unavailable, busy: loading }}
disabled={unavailable}
onPress={(event) => { feedback(haptic); onPress(event); }}
style={({ pressed }) => [styles.base, buttonSize === 'compact' && styles.compact, variantStyles[variant], fullWidth && styles.fullWidth, unavailable && styles.disabled, pressed && !unavailable && styles.pressed, style]}>
{loading ? <ActivityIndicator color={spinnerColor} /> : <><View style={styles.icon}>{leading}</View><AppText variant="label" color={labelColor}>{label}</AppText><View style={styles.icon}>{trailing}</View></>}
</Pressable>;
}
const styles = StyleSheet.create({
base: { minHeight: size.control, paddingHorizontal: spacing[5], borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[2] },
compact: { minHeight: 40, paddingHorizontal: spacing[4], borderRadius: 12 },
fullWidth: { alignSelf: 'stretch' },
pressed: { transform: [{ scale: motion.pressedScale }] },
disabled: { backgroundColor: colors.neutral.surfaceSubtle, borderColor: colors.neutral.surfaceSubtle },
icon: { minWidth: 0, alignItems: 'center', justifyContent: 'center' },
});
const variantStyles = StyleSheet.create({
primary: { backgroundColor: colors.brand.cobalt },
secondary: { backgroundColor: colors.brand.cobaltSoft },
ghost: { backgroundColor: 'transparent' },
danger: { backgroundColor: colors.semantic.danger.solid },
});

23
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { colors, radius, spacing } from './tokens';
type CardTone = 'surface' | 'sky' | 'skySoft' | 'cobaltSoft' | 'coralSoft';
type Props = { children: ReactNode; tone?: CardTone; style?: StyleProp<ViewStyle>; padding?: number; testID?: string };
const toneStyle = {
surface: { backgroundColor: colors.neutral.surface, borderWidth: 1, borderColor: colors.neutral.border },
sky: { backgroundColor: colors.brand.sky },
skySoft: { backgroundColor: colors.brand.skySoft },
cobaltSoft: { backgroundColor: colors.brand.cobaltSoft },
coralSoft: { backgroundColor: colors.brand.coralSoft },
} as const;
/** Flat grouped surface. Floating elevation is intentionally reserved for overlays. */
export function Card({ children, tone = 'surface', padding = spacing[5], style, testID }: Props) {
return <View testID={testID} style={[styles.base, toneStyle[tone], { padding }, style]}>{children}</View>;
}
const styles = StyleSheet.create({
base: { borderRadius: radius.card },
});

View File

@@ -0,0 +1,20 @@
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, spacing } from './tokens';
type Props = { time: string; style?: StyleProp<ViewStyle> };
export function CurrentTimeIndicator({ time, style }: Props) {
return <View accessibilityLabel={`Current time ${time}`} style={[styles.row, style]}>
<AppText variant="caption" color={colors.brand.coral} style={styles.time}>{time}</AppText>
<View style={styles.triangle} />
<View style={styles.line} />
</View>;
}
const styles = StyleSheet.create({
row: { height: 20, flexDirection: 'row', alignItems: 'center', marginVertical: spacing[1] },
time: { width: 46, fontVariant: ['tabular-nums'] },
triangle: { width: 0, height: 0, borderTopWidth: 5, borderBottomWidth: 5, borderLeftWidth: 7, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderLeftColor: colors.brand.coral },
line: { flex: 1, height: 2, backgroundColor: colors.brand.coral },
});

View File

@@ -0,0 +1,30 @@
import { Ionicons } from '@expo/vector-icons';
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { Card } from './card';
import { colors, radius, spacing } from './tokens';
type Props = {
icon: keyof typeof Ionicons.glyphMap;
title: string;
message: string;
action?: ReactNode;
style?: StyleProp<ViewStyle>;
};
export function EmptyState({ icon, title, message, action, style }: Props) {
return <Card style={style} padding={spacing[7]}>
<View style={styles.icon}><Ionicons name={icon} size={24} color={colors.brand.cobalt} /></View>
<AppText variant="title" style={styles.title}>{title}</AppText>
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.message}>{message}</AppText>
{action ? <View style={styles.action}>{action}</View> : null}
</Card>;
}
const styles = StyleSheet.create({
icon: { width: 48, height: 48, borderRadius: radius.control, backgroundColor: colors.brand.sky, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
title: { marginTop: spacing[4], textAlign: 'center' },
message: { marginTop: spacing[2], textAlign: 'center' },
action: { marginTop: spacing[5], alignSelf: 'stretch' },
});

View File

@@ -0,0 +1,44 @@
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { StyleSheet, TextInput, View, type StyleProp, type TextInputProps, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, size, spacing } from './tokens';
type Props = TextInputProps & {
label: string;
hint?: string;
error?: string;
containerStyle?: StyleProp<ViewStyle>;
};
/** Labeled input with visible instructions and a consistent focus/error treatment. */
export function FormField({ label, hint, error, containerStyle, editable = true, style, accessibilityLabel, accessibilityHint, onFocus, onBlur, ...inputProps }: Props) {
const [focused, setFocused] = useState(false);
return <View style={containerStyle}>
<AppText variant="label" color={colors.neutral.textSecondary} style={styles.label}>{label}</AppText>
<View style={[styles.inputWrap, focused && styles.inputFocused, error && styles.inputError, !editable && styles.inputDisabled]}>
<TextInput
{...inputProps}
editable={editable}
accessibilityLabel={accessibilityLabel || `${label}${error ? `. Error: ${error}` : ''}`}
accessibilityHint={error || accessibilityHint || hint}
placeholderTextColor={colors.neutral.textMuted}
onFocus={(event) => { setFocused(true); onFocus?.(event); }}
onBlur={(event) => { setFocused(false); onBlur?.(event); }}
style={[styles.input, style]}
/>
{error ? <Ionicons accessibilityElementsHidden name="alert-circle" size={18} color={colors.semantic.danger.text} /> : null}
</View>
{error ? <AppText variant="caption" color={colors.semantic.danger.text} style={styles.support}>{error}</AppText> : hint ? <AppText variant="caption" color={colors.neutral.textMuted} style={styles.support}>{hint}</AppText> : null}
</View>;
}
const styles = StyleSheet.create({
label: { marginBottom: spacing[2] },
inputWrap: { minHeight: size.control, flexDirection: 'row', alignItems: 'center', borderRadius: radius.control, borderWidth: 1, borderColor: colors.neutral.border, backgroundColor: colors.neutral.surface, paddingHorizontal: spacing[4] },
inputFocused: { borderColor: colors.brand.cobalt, borderWidth: 2, paddingHorizontal: spacing[3] + 1 },
inputError: { borderColor: colors.semantic.danger.solid, borderWidth: 2, paddingHorizontal: spacing[3] + 1 },
inputDisabled: { backgroundColor: colors.neutral.surfaceSubtle },
input: { flex: 1, minHeight: size.control - 2, paddingVertical: 0, color: colors.neutral.textPrimary, fontFamily: 'Manrope_400Regular', fontSize: 15, lineHeight: 22 },
support: { marginTop: spacing[1] },
});

View File

@@ -0,0 +1,41 @@
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
import { colors, motion, radius, size } from './tokens';
type Tone = 'soft' | 'sky' | 'ghost' | 'danger';
type Props = {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: (event: GestureResponderEvent) => void;
tone?: Tone;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
export function IconButton({ icon, label, onPress, tone = 'soft', disabled = false, style, testID }: Props) {
const iconColor = tone === 'danger' ? colors.semantic.danger.text : tone === 'ghost' ? colors.neutral.textPrimary : colors.brand.cobalt;
return <Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
disabled={disabled}
hitSlop={4}
onPress={onPress}
style={({ pressed }) => [styles.base, toneStyles[tone], disabled && styles.disabled, pressed && !disabled && styles.pressed, style]}>
<Ionicons name={icon} size={size.icon} color={iconColor} />
</Pressable>;
}
const styles = StyleSheet.create({
base: { width: size.touchTargetMin, height: size.touchTargetMin, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center' },
disabled: { opacity: 0.48 },
pressed: { transform: [{ scale: motion.pressedScale }] },
});
const toneStyles = StyleSheet.create({
soft: { backgroundColor: colors.brand.cobaltSoft },
sky: { backgroundColor: colors.brand.sky },
ghost: { backgroundColor: 'transparent' },
danger: { backgroundColor: colors.semantic.danger.soft },
});

22
components/ui/index.ts Normal file
View File

@@ -0,0 +1,22 @@
export { AppHeader } from './app-header';
export { AppText } from './app-text';
export { AttendanceRing } from './attendance-ring';
export { BottomSheet } from './bottom-sheet';
export { Button } from './button';
export { Card } from './card';
export { CurrentTimeIndicator } from './current-time-indicator';
export { EmptyState } from './empty-state';
export { FormField } from './form-field';
export { IconButton } from './icon-button';
export { InlineBanner } from './inline-banner';
export { ProgressBar } from './progress-bar';
export { RecessCard } from './recess-card';
export { ScheduleEventCard } from './schedule-event-card';
export type { ClassAttendanceState } from './schedule-event-card';
export { Screen } from './screen';
export { StatusPill } from './status-pill';
export { SubjectBadge } from './subject-badge';
export { WeekStrip } from './week-strip';
export type { WeekDay } from './week-strip';
export { colors, motion, radius, shadow, size, spacing, type } from './tokens';
export type { SemanticTone, SubjectTone, TypeVariant } from './tokens';

View File

@@ -0,0 +1,35 @@
import { Ionicons } from '@expo/vector-icons';
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing, type SemanticTone } from './tokens';
type Props = {
title: string;
message?: string;
tone?: SemanticTone;
action?: ReactNode;
style?: StyleProp<ViewStyle>;
};
const icon = { success: 'checkmark-circle', warning: 'alert-circle', danger: 'alert-circle', neutral: 'information-circle' } as const;
/** Concise, data-backed feedback for save states and attendance insights. */
export function InlineBanner({ title, message, tone = 'neutral', action, style }: Props) {
const palette = colors.semantic[tone];
return <View accessibilityRole="alert" style={[styles.banner, { backgroundColor: palette.soft }, style]}>
<Ionicons name={icon[tone]} size={20} color={palette.text} />
<View style={styles.copy}>
<AppText variant="label" color={palette.text}>{title}</AppText>
{message ? <AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.message}>{message}</AppText> : null}
</View>
{action ? <View style={styles.action}>{action}</View> : null}
</View>;
}
const styles = StyleSheet.create({
banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], borderRadius: radius.card, padding: spacing[4] },
copy: { flex: 1 },
message: { marginTop: spacing[1] },
action: { alignSelf: 'center' },
});

View File

@@ -0,0 +1,27 @@
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { colors } from './tokens';
type Props = {
value: number;
color?: string;
height?: number;
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
export function ProgressBar({ value, color = colors.brand.cobalt, height = 8, accessibilityLabel, style }: Props) {
const percentage = Math.max(0, Math.min(100, Math.round(value)));
return <View
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{ min: 0, max: 100, now: percentage }}
style={[styles.track, { height, borderRadius: height / 2 }, style]}>
<View style={[styles.fill, { width: `${percentage}%`, minWidth: percentage > 0 ? Math.min(3, height) : 0, backgroundColor: color, borderRadius: height / 2 }]} />
</View>;
}
const styles = StyleSheet.create({
track: { overflow: 'hidden', backgroundColor: colors.neutral.surfaceSubtle },
fill: { height: '100%' },
});

View File

@@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing } from './tokens';
type Props = {
timeRange: string;
isNow?: boolean;
style?: StyleProp<ViewStyle>;
};
/** A quiet timeline block for a configured recess or lunch period. */
export function RecessCard({ timeRange, isNow = false, style }: Props) {
return <View accessibilityLabel={`Recess, ${timeRange}`} style={[styles.card, style]}>
<View style={styles.icon}>
<Ionicons name="cafe-outline" size={19} color={colors.neutral.textSecondary} />
</View>
<View style={styles.copy}>
<View style={styles.heading}>
<AppText variant="title">Recess</AppText>
{isNow ? <View style={styles.now}><AppText variant="caption" color={colors.brand.ink}>Now</AppText></View> : null}
</View>
<AppText variant="bodySmall" color={colors.neutral.textMuted}>Time to recharge</AppText>
<AppText variant="caption" color={colors.neutral.textSecondary} style={styles.range}>{timeRange}</AppText>
</View>
</View>;
}
const styles = StyleSheet.create({
card: { minHeight: 96, flexDirection: 'row', alignItems: 'center', gap: spacing[4], borderRadius: radius.feature, padding: spacing[5], backgroundColor: colors.neutral.surfaceSubtle, borderWidth: 1, borderColor: colors.neutral.divider },
icon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
copy: { flex: 1 },
heading: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3] },
now: { borderRadius: radius.pill, backgroundColor: colors.brand.coral, paddingHorizontal: spacing[3], paddingVertical: 3 },
range: { marginTop: spacing[2], fontVariant: ['tabular-nums'] },
});

View File

@@ -0,0 +1,81 @@
import { Ionicons } from '@expo/vector-icons';
import type { ReactNode } from 'react';
import { Pressable, StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing, type SubjectTone } from './tokens';
export type ClassAttendanceState = 'pending' | 'attended' | 'absent' | 'cancelled';
type Props = {
title: string;
timeRange: string;
subjectTone: SubjectTone;
room?: string;
kind?: string;
classType?: string;
state?: ClassAttendanceState;
isNow?: boolean;
topAction?: ReactNode;
footer?: ReactNode;
onPress?: (event: GestureResponderEvent) => void;
style?: StyleProp<ViewStyle>;
testID?: string;
};
/** Editorial calendar block: subject color leads; attendance changes only the card state. */
export function ScheduleEventCard({ title, timeRange, subjectTone, room, kind, classType = 'Lecture', state = 'pending', isNow = false, topAction, footer, onPress, style, testID }: Props) {
const subject = colors.subject[subjectTone];
const cancelled = state === 'cancelled';
const absent = state === 'absent';
const backgroundColor = cancelled ? colors.neutral.surfaceSubtle : absent ? colors.semantic.danger.soft : subject.surface;
const foreground = cancelled ? colors.neutral.textMuted : absent ? colors.semantic.danger.text : subject.accent;
const content = <>
<View style={styles.titleRow}>
<AppText variant="title" color={foreground} numberOfLines={2} style={styles.title}>{title}</AppText>
{topAction ? <View style={styles.topAction}>{topAction}</View> : null}
</View>
<View style={styles.meta}>
<AppText variant="bodySmall" color={foreground} numberOfLines={1} style={styles.code}>{kind || 'Class'}</AppText>
{room ? <View style={styles.room}><AppText variant="bodySmall" color={cancelled ? colors.neutral.textMuted : colors.neutral.textSecondary} numberOfLines={1}>{room}</AppText></View> : null}
</View>
<View style={styles.bottomRow}>
<View style={styles.typeRow}>
<Ionicons name="school-outline" size={14} color={foreground} />
<AppText variant="label" color={foreground}>{classType}</AppText>
</View>
{isNow ? <View style={styles.now}><AppText variant="caption" color={colors.brand.ink}>Now</AppText></View> : cancelled ? <AppText variant="label" color={colors.neutral.textMuted}>Cancelled</AppText> : <ParticipantStack accent={foreground} />}
</View>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</>;
const cardStyle = [styles.card, { backgroundColor }, cancelled && styles.cancelled, style];
if (!onPress) return <View testID={testID} accessibilityLabel={`${title}, ${timeRange}${room ? `, ${room}` : ''}`} style={cardStyle}>{content}</View>;
return <Pressable testID={testID} accessibilityRole="button" accessibilityLabel={`${title}, ${timeRange}${room ? `, ${room}` : ''}`} onPress={onPress} style={({ pressed }) => [cardStyle, pressed && styles.pressed]}>{content}</Pressable>;
}
function ParticipantStack({ accent }: { accent: string }) {
return <View accessibilityLabel="Three classmates and eight more" style={styles.participants}>
{['A', 'K', 'S'].map((initial, index) => <View key={initial} style={[styles.avatar, { backgroundColor: index === 0 ? colors.brand.sky : index === 1 ? colors.subject.lilac.surface : colors.subject.peach.surface, marginLeft: index ? -spacing[2] : 0 }]}>
<AppText variant="caption" color={accent}>{initial}</AppText>
</View>)}
<AppText variant="label" color={accent} style={styles.more}>+8</AppText>
</View>;
}
const styles = StyleSheet.create({
card: { minHeight: 116, overflow: 'hidden', borderRadius: radius.feature, padding: spacing[5] },
titleRow: { minHeight: 40, flexDirection: 'row', alignItems: 'flex-start', gap: spacing[2] },
title: { flex: 1, paddingTop: spacing[1] },
topAction: { marginRight: -spacing[3], marginTop: -spacing[3] },
meta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3], marginTop: spacing[1] },
code: { flexShrink: 1 },
room: { maxWidth: '52%', alignItems: 'flex-end' },
bottomRow: { minHeight: 30, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[3], marginTop: spacing[3] },
typeRow: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
now: { borderRadius: radius.pill, backgroundColor: colors.brand.coral, paddingHorizontal: spacing[3], paddingVertical: 3 },
participants: { flexDirection: 'row', alignItems: 'center' },
avatar: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, borderWidth: 2, borderColor: colors.neutral.surface },
more: { marginLeft: spacing[2] },
footer: { marginTop: spacing[3] },
cancelled: { opacity: 0.72 },
pressed: { opacity: 0.78 },
});

40
components/ui/screen.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { ReactNode } from 'react';
import { ScrollView, StyleSheet, useWindowDimensions, View, type StyleProp, type ViewStyle } from 'react-native';
import { SafeAreaView, type Edge } from 'react-native-safe-area-context';
import { colors, size, spacing } from './tokens';
type Props = {
children: ReactNode;
scroll?: boolean;
edges?: Edge[];
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string;
};
/** Consistent canvas, safe-area behavior, horizontal gutter, and readable content width. */
export function Screen({ children, scroll = true, edges = ['top'], style, contentContainerStyle, testID }: Props) {
const { width } = useWindowDimensions();
const responsiveGutter = width >= 430 ? size.screenGutterWide : size.screenGutter;
const content = scroll ? (
<ScrollView
testID={testID}
style={styles.scroll}
contentContainerStyle={[styles.content, { paddingHorizontal: responsiveGutter }, contentContainerStyle, { paddingBottom: size.tabBar + spacing[6] }]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled">
{children}
</ScrollView>
) : (
<View testID={testID} style={[styles.content, styles.fill, { paddingHorizontal: responsiveGutter }, contentContainerStyle]}>{children}</View>
);
return <SafeAreaView edges={edges} style={[styles.safe, style]}>{content}</SafeAreaView>;
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.neutral.canvas },
scroll: { flex: 1 },
content: { width: '100%', maxWidth: size.contentMaxWidth, alignSelf: 'center', paddingTop: spacing[5], paddingBottom: spacing[8] },
fill: { flex: 1 },
});

View File

@@ -0,0 +1,23 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing, type SemanticTone } from './tokens';
type Props = {
label: string;
tone?: SemanticTone;
icon?: keyof typeof Ionicons.glyphMap;
style?: StyleProp<ViewStyle>;
};
export function StatusPill({ label, tone = 'neutral', icon, style }: Props) {
const palette = colors.semantic[tone];
return <View accessibilityRole="text" style={[styles.pill, { backgroundColor: palette.soft }, style]}>
{icon && <Ionicons name={icon} size={14} color={palette.text} />}
<AppText variant="caption" color={palette.text}>{label}</AppText>
</View>;
}
const styles = StyleSheet.create({
pill: { minHeight: 28, alignSelf: 'flex-start', alignItems: 'center', flexDirection: 'row', gap: spacing[1], borderRadius: radius.pill, paddingHorizontal: spacing[3], paddingVertical: spacing[1] },
});

View File

@@ -0,0 +1,19 @@
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, type SubjectTone } from './tokens';
type Props = { shortName: string; tone: SubjectTone; size?: 'small' | 'medium'; style?: StyleProp<ViewStyle> };
/** Stable subject identifier. Use the same tone for a subject everywhere in the product. */
export function SubjectBadge({ shortName, tone, size = 'medium', style }: Props) {
const subject = colors.subject[tone];
return <View accessibilityRole="text" style={[styles.base, size === 'small' ? styles.small : styles.medium, { backgroundColor: subject.surface }, style]}>
<AppText variant={size === 'small' ? 'caption' : 'label'} color={subject.accent} numberOfLines={1}>{shortName}</AppText>
</View>;
}
const styles = StyleSheet.create({
base: { alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
small: { minWidth: 32, height: 32, paddingHorizontal: 6 },
medium: { minWidth: 44, height: 44, paddingHorizontal: 8, borderRadius: 14 },
});

87
components/ui/tokens.ts Normal file
View File

@@ -0,0 +1,87 @@
import { Platform } from 'react-native';
/**
* Runtime token mirror for designs/design-tokens.json.
* Components must consume this module rather than introduce screen-local values.
*/
export const colors = {
brand: {
ink: '#061430',
cobalt: '#0559FA',
cobaltPressed: '#0047D5',
cobaltSoft: '#E7F0FF',
coral: '#FF7A4F',
coralSoft: '#FFE9E1',
sky: '#D5F2FF',
skySoft: '#F0FAFF',
},
neutral: {
canvas: '#F7F9FC',
surface: '#FFFFFF',
surfaceSubtle: '#F0F4F8',
textPrimary: '#061430',
textSecondary: '#4E6078',
textMuted: '#68788F',
textDisabled: '#9AA8B8',
border: '#DDE5EE',
divider: '#E9EEF4',
scrim: 'rgba(6, 20, 48, 0.46)',
},
semantic: {
success: { solid: '#168A63', text: '#117A57', soft: '#DDF5EC' },
warning: { solid: '#F0B44D', text: '#8A5900', soft: '#FFF4CE' },
danger: { solid: '#CF4038', text: '#A82E2A', soft: '#FFE9E7' },
neutral: { solid: '#68788F', text: '#4E6078', soft: '#EDF1F5' },
},
subject: {
ocean: { surface: '#BFD5FF', accent: '#0559FA', text: '#061430' },
aqua: { surface: '#DDF7FA', accent: '#167F96', text: '#061430' },
lilac: { surface: '#FDF0FF', accent: '#9B4BA4', text: '#061430' },
sun: { surface: '#FFF3C4', accent: '#8A6400', text: '#061430' },
mint: { surface: '#DDF5EC', accent: '#117A57', text: '#061430' },
peach: { surface: '#FFE5D9', accent: '#B95230', text: '#061430' },
},
} as const;
export const spacing = { 0: 0, 1: 2, 2: 4, 3: 8, 4: 12, 5: 16, 6: 20, 7: 24, 8: 32, 9: 40, 10: 48, 11: 64 } as const;
export const radius = { small: 8, control: 12, card: 16, feature: 20, sheet: 28, pill: 999 } as const;
export const size = { touchTargetMin: 44, control: 52, controlCompact: 40, iconSmall: 16, icon: 20, iconLarge: 24, iconContainer: 40, screenGutter: 20, screenGutterWide: 24, contentMaxWidth: 560, tabBar: 68 } as const;
const fontFaces = {
regular: 'Manrope_400Regular',
medium: 'Manrope_500Medium',
semibold: 'Manrope_600SemiBold',
bold: 'Manrope_700Bold',
extrabold: 'Manrope_800ExtraBold',
} as const;
const systemFont = Platform.select({ ios: 'System', android: 'sans-serif', default: 'system-ui' });
const font = (face: keyof typeof fontFaces) => ({ fontFamily: fontFaces[face] || systemFont });
export const type = {
display: { ...font('extrabold'), fontSize: 40, lineHeight: 44, letterSpacing: -1.2 },
heading1: { ...font('extrabold'), fontSize: 32, lineHeight: 38, letterSpacing: -0.8 },
heading2: { ...font('bold'), fontSize: 24, lineHeight: 30, letterSpacing: -0.4 },
heading3: { ...font('bold'), fontSize: 20, lineHeight: 26, letterSpacing: -0.2 },
title: { ...font('bold'), fontSize: 17, lineHeight: 23, letterSpacing: 0 },
body: { ...font('regular'), fontSize: 15, lineHeight: 22, letterSpacing: 0 },
bodySmall: { ...font('regular'), fontSize: 13, lineHeight: 19, letterSpacing: 0 },
label: { ...font('semibold'), fontSize: 12, lineHeight: 16, letterSpacing: 0.1 },
caption: { ...font('medium'), fontSize: 11, lineHeight: 15, letterSpacing: 0.1 },
} as const;
export type TypeVariant = keyof typeof type;
export type SubjectTone = keyof typeof colors.subject;
export type SemanticTone = keyof typeof colors.semantic;
export const shadow = {
floating: {
shadowColor: colors.brand.ink,
shadowOpacity: 0.12,
shadowRadius: 18,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
} as const;
export const motion = { instant: 100, quick: 160, standard: 220, emphasized: 320, pressedScale: 0.98 } as const;

View File

@@ -0,0 +1,56 @@
import * as Haptics from 'expo-haptics';
import { Platform, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { AppText } from './app-text';
import { colors, radius, spacing } from './tokens';
export type WeekDay = { date: Date; disabled?: boolean; marker?: 'none' | 'success' | 'warning' | 'danger' | 'neutral' };
type Props = {
days: WeekDay[];
selectedDateKey: string;
onSelect: (date: Date) => void;
todayDateKey?: string;
style?: StyleProp<ViewStyle>;
accessibilityLabel?: string;
};
const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const markerColor = { none: 'transparent', success: colors.semantic.success.solid, warning: colors.semantic.warning.solid, danger: colors.semantic.danger.solid, neutral: colors.neutral.textMuted } as const;
/** The shared selected-date control: coral selection, cobalt today signal, semantic status dot. */
export function WeekStrip({ days, selectedDateKey, onSelect, todayDateKey, style, accessibilityLabel = 'Choose a day' }: Props) {
return <View accessibilityLabel={accessibilityLabel} style={[styles.strip, style]}>
{days.map(({ date, disabled = false, marker = 'none' }) => {
const key = dateKey(date);
const selected = key === selectedDateKey;
const today = key === todayDateKey;
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(date);
const fullDate = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(date);
return <Pressable
key={key}
accessibilityRole="button"
accessibilityLabel={fullDate}
accessibilityState={{ selected, disabled }}
disabled={disabled}
onPress={() => { if (Platform.OS !== 'web') void Haptics.selectionAsync(); onSelect(date); }}
style={({ pressed }) => [styles.day, selected && styles.selected, disabled && styles.disabled, pressed && !disabled && styles.pressed]}>
<AppText variant="caption" color={selected ? colors.brand.ink : colors.neutral.textSecondary}>{weekday}</AppText>
<AppText variant="title" color={selected ? colors.brand.ink : colors.neutral.textPrimary} style={styles.number}>{date.getDate()}</AppText>
<View style={styles.indicatorRow}>
<View style={[styles.dot, today && !selected && styles.todayDot, marker !== 'none' && { backgroundColor: markerColor[marker] }]} />
</View>
</Pressable>;
})}
</View>;
}
const styles = StyleSheet.create({
strip: { flexDirection: 'row', gap: spacing[1], padding: spacing[2], borderRadius: radius.feature, backgroundColor: colors.brand.sky },
day: { flex: 1, minHeight: 60, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control, paddingVertical: spacing[1] },
selected: { backgroundColor: colors.brand.coral },
disabled: { opacity: 0.42 },
pressed: { opacity: 0.78 },
number: { marginTop: 1, fontVariant: ['tabular-nums'] },
indicatorRow: { height: 5, marginTop: 2, justifyContent: 'center' },
dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: 'transparent' },
todayDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.brand.cobalt },
});

View File

@@ -1,53 +1,33 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
const cobalt = '#0559FA';
const ink = '#061430';
const canvas = '#F7F9FC';
const muted = '#68788F';
/** Legacy-compatible theme exports backed by the CLG Kit light design system. */
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
text: ink,
background: canvas,
tint: cobalt,
icon: muted,
tabIconDefault: muted,
tabIconSelected: cobalt,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
text: ink,
background: canvas,
tint: cobalt,
icon: muted,
tabIconDefault: muted,
tabIconSelected: cobalt,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
ios: { sans: 'Manrope_400Regular', serif: 'ui-serif', rounded: 'Manrope_600SemiBold', mono: 'ui-monospace' },
android: { sans: 'Manrope_400Regular', serif: 'serif', rounded: 'Manrope_600SemiBold', mono: 'monospace' },
default: { sans: 'Manrope_400Regular', serif: 'serif', rounded: 'Manrope_600SemiBold', mono: 'monospace' },
web: { sans: "Manrope_400Regular, system-ui, sans-serif", serif: "Georgia, 'Times New Roman', serif", rounded: "Manrope_600SemiBold, system-ui, sans-serif", mono: "SFMono-Regular, Menlo, Monaco, Consolas, monospace" },
});

BIN
designs/37400450.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

BIN
designs/37400451.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

1003
designs/DESIGN_SYSTEM.md Normal file

File diff suppressed because it is too large Load Diff

223
designs/design-tokens.json Normal file
View File

@@ -0,0 +1,223 @@
{
"meta": {
"name": "CLG Kit Design Tokens",
"version": "1.0.0",
"fontFamily": "Manrope",
"units": {
"dimension": "dp",
"type": "sp",
"motion": "ms"
}
},
"color": {
"brand": {
"ink": "#061430",
"cobalt": "#0559FA",
"cobaltPressed": "#0047D5",
"cobaltSoft": "#E7F0FF",
"coral": "#FF7A4F",
"coralSoft": "#FFE9E1",
"sky": "#D5F2FF",
"skySoft": "#F0FAFF"
},
"neutral": {
"canvas": "#F7F9FC",
"surface": "#FFFFFF",
"surfaceSubtle": "#F0F4F8",
"presentationBackdrop": "#E6EBF4",
"textPrimary": "#061430",
"textSecondary": "#4E6078",
"textMuted": "#68788F",
"textDisabled": "#9AA8B8",
"border": "#DDE5EE",
"divider": "#E9EEF4",
"scrim": "rgba(6, 20, 48, 0.46)"
},
"semantic": {
"success": {
"solid": "#168A63",
"text": "#117A57",
"soft": "#DDF5EC"
},
"warning": {
"solid": "#F0B44D",
"text": "#8A5900",
"soft": "#FFF4CE"
},
"danger": {
"solid": "#CF4038",
"text": "#A82E2A",
"soft": "#FFE9E7"
},
"neutral": {
"solid": "#68788F",
"text": "#4E6078",
"soft": "#EDF1F5"
}
},
"subject": {
"ocean": {
"surface": "#BFD5FF",
"accent": "#0559FA",
"text": "#061430"
},
"aqua": {
"surface": "#DDF7FA",
"accent": "#167F96",
"text": "#061430"
},
"lilac": {
"surface": "#FDF0FF",
"accent": "#9B4BA4",
"text": "#061430"
},
"sun": {
"surface": "#FFF3C4",
"accent": "#8A6400",
"text": "#061430"
},
"mint": {
"surface": "#DDF5EC",
"accent": "#117A57",
"text": "#061430"
},
"peach": {
"surface": "#FFE5D9",
"accent": "#B95230",
"text": "#061430"
}
}
},
"typography": {
"family": {
"primary": "Manrope",
"fallback": "system-ui"
},
"weight": {
"regular": 400,
"medium": 500,
"semibold": 600,
"bold": 700,
"extrabold": 800
},
"style": {
"display": {
"fontSize": 40,
"lineHeight": 44,
"fontWeight": 800,
"letterSpacing": -1.2
},
"heading1": {
"fontSize": 32,
"lineHeight": 38,
"fontWeight": 800,
"letterSpacing": -0.8
},
"heading2": {
"fontSize": 24,
"lineHeight": 30,
"fontWeight": 700,
"letterSpacing": -0.4
},
"heading3": {
"fontSize": 20,
"lineHeight": 26,
"fontWeight": 700,
"letterSpacing": -0.2
},
"title": {
"fontSize": 17,
"lineHeight": 23,
"fontWeight": 700,
"letterSpacing": 0
},
"body": {
"fontSize": 15,
"lineHeight": 22,
"fontWeight": 400,
"letterSpacing": 0
},
"bodySmall": {
"fontSize": 13,
"lineHeight": 19,
"fontWeight": 400,
"letterSpacing": 0
},
"label": {
"fontSize": 12,
"lineHeight": 16,
"fontWeight": 600,
"letterSpacing": 0.1
},
"caption": {
"fontSize": 11,
"lineHeight": 15,
"fontWeight": 500,
"letterSpacing": 0.1
}
}
},
"spacing": {
"0": 0,
"1": 2,
"2": 4,
"3": 8,
"4": 12,
"5": 16,
"6": 20,
"7": 24,
"8": 32,
"9": 40,
"10": 48,
"11": 64
},
"radius": {
"small": 8,
"control": 12,
"card": 16,
"feature": 20,
"sheet": 28,
"pill": 999
},
"size": {
"touchTargetMin": 44,
"control": 52,
"controlCompact": 40,
"iconSmall": 16,
"icon": 20,
"iconLarge": 24,
"iconContainer": 40,
"screenGutter": 20,
"screenGutterWide": 24,
"contentMaxWidth": 560,
"tabBar": 68
},
"border": {
"hairline": 1,
"focus": 2,
"accent": 4
},
"shadow": {
"none": {
"color": "#061430",
"opacity": 0,
"radius": 0,
"offsetY": 0,
"elevation": 0
},
"floating": {
"color": "#061430",
"opacity": 0.12,
"radius": 18,
"offsetY": 8,
"elevation": 8
}
},
"motion": {
"instant": 100,
"quick": 160,
"standard": 220,
"emphasized": 320,
"pressedScale": 0.98
}
}

BIN
designs/timeline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
api:
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
PORT: 4000
volumes:
- college-kit-data:/app/backend/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
web:
build:
context: .
dockerfile: Dockerfile
args:
EXPO_PUBLIC_API_URL: /api/v1
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "8080:80"
volumes:
college-kit-data:
name: college-kit-data

26
docker/nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The web bundle calls /api/v1 so it stays same-origin in Docker.
location /api/ {
proxy_pass http://api:4000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://api:4000/health;
}
# Expo Router static output and client-side routes.
location / {
try_files $uri $uri/ /index.html;
}
}

36
lib/api.ts Normal file
View File

@@ -0,0 +1,36 @@
export type AttendanceStatus = 'pending' | 'attended' | 'absent' | 'cancelled';
export type Session = { id: number; subjectId: number; date: string; time: string; endTime: string; title: string; code: string; room: string; color: string; classType: string; status: AttendanceStatus };
// Set EXPO_PUBLIC_API_URL to your computer's LAN address when testing on a phone,
// e.g. EXPO_PUBLIC_API_URL=http://192.168.1.20:4000/api/v1
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_URL}${path}`, { headers: { 'Content-Type': 'application/json', ...(options?.headers ?? {}) }, ...options });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error ?? 'Unable to reach College Kit');
}
return response.json() as Promise<T>;
}
export type Profile = { id: number; name: string; initials: string; college: string; programme: string; semester: string; lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean };
export type Subject = { id: number; name: string; code: string; shortName: string; color: string; classType: string; defaultRoom: string };
export const collegeApi = {
profile() { return request<Profile>('/profile'); },
updateProfile(data: { name: string; college: string; programme?: string; semester?: string }) { return request<Profile>('/profile', { method: 'PUT', body: JSON.stringify(data) }); },
updateSettings(data: { lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean }) { return request<{ lectureMinutes: number; recessEnabled: boolean; recessStart: string; recessEnd: string; weekendSchedule: boolean }>('/settings', { method: 'PATCH', body: JSON.stringify(data) }); },
subjects() { return request<Subject[]>('/subjects'); },
createSubject(data: { name: string; code: string; shortName?: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>('/subjects', { method: 'POST', body: JSON.stringify(data) }); },
subject(id: number) { return request<{ id: number; name: string; code: string; shortName: string; color: string; classType: string; defaultRoom: string; summary: { total: number; attended: number; absent: number; percentage: number }; sessions: Array<{ id: number; date: string; time: string; endTime: string; room: string; status: AttendanceStatus }> }>(`/subjects/${id}`); },
updateSubject(id: number, data: { name: string; code: string; shortName: string; color?: string; classType?: string; defaultRoom?: string }) { return request<{ id: number }>(`/subjects/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
schedule(date: string) { return request<{ date: string; sessions: Session[] }>(`/schedule?date=${date}`); },
markAttendance(id: number, status: AttendanceStatus) { return request<{ id: number; status: AttendanceStatus }>(`/schedule/${id}/attendance`, { method: 'PUT', body: JSON.stringify({ status }) }); },
removeClass(id: number) { return request<{ id: number; deleted: boolean }>(`/schedule/${id}`, { method: 'DELETE' }); },
addOneOffClass(data: { subjectId: number; date: string; startTime: string; endTime: string; room: string }) { return request<{ id: number }>('/schedule', { method: 'POST', body: JSON.stringify(data) }); },
attendanceSummary() { return request<{ subjects: Array<{ id: number; name: string; code: string; shortName?: string; color: string; total: number; attended: number; absent: number; cancelled: number; percentage: number }> }>('/attendance/summary'); },
timetable(date: string) { return request<{ date: string; classes: Array<{ id: number; weekday: number; startTime: string; endTime: string; room: string; subjectId: number; subjectName: string; code: string; color: string; classType: string; effectiveFrom: string; effectiveTo: string | null }> }>(`/timetable/classes?date=${date}`); },
updateTimetableClass(id: number, data: { subjectId?: number; weekday?: number; startTime?: string; endTime?: string; room?: string; effectiveFrom?: string }) { return request<{ id: number; replacesId: number; effectiveFrom: string }>(`/timetable/classes/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); },
addTimetableClass(data: { subjectId: number; weekday: number; startTime: string; endTime: string; room: string; effectiveFrom?: string }) { return request<{ id: number }>('/timetable/classes', { method: 'POST', body: JSON.stringify(data) }); },
};

34
lib/date.ts Normal file
View File

@@ -0,0 +1,34 @@
export const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
export const addDays = (date: Date, amount: number) => {
const next = new Date(date);
next.setDate(next.getDate() + amount);
return next;
};
export const mondayOfWeek = (date: Date) => addDays(date, -((date.getDay() + 6) % 7));
export const dateFromKey = (value: string) => new Date(`${value}T12:00:00`);
export const formatDayHeading = (date: Date) => new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(date);
export const formatMonthDay = (date: Date) => new Intl.DateTimeFormat(undefined, { month: 'long', day: 'numeric' }).format(date);
export const formatSessionDate = (value: string) => new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(dateFromKey(value));
export const currentMinutes = () => {
const now = new Date();
return now.getHours() * 60 + now.getMinutes();
};
export const timeToMinutes = (value: string) => {
const [hours, minutes] = value.split(':').map(Number);
return Number.isFinite(hours) && Number.isFinite(minutes) ? hours * 60 + minutes : -1;
};
export const greetingFor = (date = new Date()) => {
const hour = date.getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};

31
lib/design.ts Normal file
View File

@@ -0,0 +1,31 @@
import { colors, type SemanticTone, type SubjectTone } from '@/components/ui';
const subjectTones = Object.keys(colors.subject) as SubjectTone[];
const hash = (value: string | number) => {
const input = String(value);
let result = 0;
for (let index = 0; index < input.length; index += 1) result = ((result << 5) - result + input.charCodeAt(index)) | 0;
return Math.abs(result);
};
/** Maps persisted subjects to one stable accessible surface/accent pair. */
export function subjectToneFor(identity: string | number, savedColor?: string): SubjectTone {
const normalized = savedColor?.toUpperCase();
const matched = subjectTones.find((tone) => colors.subject[tone].accent.toUpperCase() === normalized || colors.subject[tone].surface.toUpperCase() === normalized);
return matched ?? subjectTones[hash(identity) % subjectTones.length];
}
export function attendanceTone(percentage: number, total: number, threshold = 75): SemanticTone {
if (!total) return 'neutral';
if (percentage >= threshold) return 'success';
if (percentage >= threshold - 10) return 'warning';
return 'danger';
}
export function attendanceMessage(percentage: number, total: number, threshold = 75) {
if (!total) return { title: 'No attendance yet', message: 'Mark your first class to start tracking progress.' };
if (percentage >= threshold + 5) return { title: 'Youre on track', message: `${percentage}% overall · Keep it above ${threshold}%.` };
if (percentage >= threshold) return { title: 'Youre just above the target', message: `${percentage}% overall · Your target is ${threshold}%.` };
return { title: 'Attendance needs attention', message: `${percentage}% overall · Your target is ${threshold}%.` };
}

164
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "clg-kit",
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/manrope": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -31,6 +32,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
@@ -1626,6 +1628,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@expo-google-fonts/manrope": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/manrope/-/manrope-0.4.2.tgz",
"integrity": "sha512-BZsKe8d9BJrVnIQIZcTS7/Kac0TbXnqs/+8EBSiQxrmK6GoCO6eTkmr50D1weIk/EoF20pTmAkpWICnovATr/g==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -4273,6 +4281,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4755,6 +4769,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4953,6 +5017,61 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -5015,6 +5134,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -8396,6 +8527,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -8917,6 +9054,18 @@
"node": ">=10"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -9797,6 +9946,21 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.12.1",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
"integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",

View File

@@ -8,9 +8,11 @@
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"api": "node backend/server.mjs"
},
"dependencies": {
"@expo-google-fonts/manrope": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -31,17 +33,18 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0"
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0"
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
},
"private": true
}