Update app and ignore local artifacts
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
8
.gitignore
vendored
@@ -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
15
Dockerfile
Normal 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
|
||||
70
README.md
70
README.md
@@ -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.
|
||||
|
||||
7
app.json
7
app.json
@@ -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"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { BottomTabBar } from '@/components/ui/bottom-tab-bar';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
return <Tabs
|
||||
tabBar={(props) => <BottomTabBar {...props} />}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// The visible dock is independently absolute; this removes React Navigation's reserved tab-bar scene area.
|
||||
tabBarStyle: { position: 'absolute', height: 0, backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0 },
|
||||
}}>
|
||||
<Tabs.Screen name="index" options={{ title: 'Today' }} />
|
||||
<Tabs.Screen name="timetable" options={{ title: 'Timetable' }} />
|
||||
<Tabs.Screen name="attendance" options={{ title: 'Attendance' }} />
|
||||
<Tabs.Screen name="settings" options={{ title: 'Settings' }} />
|
||||
</Tabs>;
|
||||
}
|
||||
|
||||
163
app/(tabs)/attendance.tsx
Normal file
163
app/(tabs)/attendance.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||
|
||||
import {
|
||||
AppHeader,
|
||||
AppText,
|
||||
AttendanceRing,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
InlineBanner,
|
||||
ProgressBar,
|
||||
Screen,
|
||||
StatusPill,
|
||||
SubjectBadge,
|
||||
colors,
|
||||
spacing,
|
||||
} from '@/components/ui';
|
||||
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi } from '@/lib/api';
|
||||
|
||||
type Subject = Awaited<ReturnType<typeof collegeApi.attendanceSummary>>['subjects'][number];
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const router = useRouter();
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
collegeApi.attendanceSummary()
|
||||
.then(({ subjects: result }) => setSubjects(result))
|
||||
.catch((error: Error) => { setSubjects([]); setLoadError(error.message); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useFocusEffect(useCallback(() => { load(); }, [load]));
|
||||
|
||||
const totalHeld = subjects.reduce((total, subject) => total + subject.total, 0);
|
||||
const totalAttended = subjects.reduce((total, subject) => total + subject.attended, 0);
|
||||
const totalMissed = subjects.reduce((total, subject) => total + subject.absent, 0);
|
||||
const overall = totalHeld ? Math.round(totalAttended / totalHeld * 100) : 0;
|
||||
const overallTone = attendanceTone(overall, totalHeld);
|
||||
const insight = attendanceMessage(overall, totalHeld);
|
||||
const sortedSubjects = [...subjects].sort((left, right) => {
|
||||
if (!left.total && right.total) return 1;
|
||||
if (left.total && !right.total) return -1;
|
||||
return left.percentage - right.percentage || left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader title="Attendance" />
|
||||
|
||||
{loadError ? <InlineBanner title="Couldn’t load attendance" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} style={styles.firstBlock} /> : null}
|
||||
{loading ? <Card tone="skySoft" style={[styles.loading, styles.firstBlock]}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Calculating attendance…</AppText></Card> : null}
|
||||
|
||||
{!loading && !loadError ? <>
|
||||
<Card tone="sky" padding={spacing[6]} style={styles.hero}>
|
||||
<AttendanceRing
|
||||
percentage={overall}
|
||||
tone={totalHeld ? (overallTone === 'neutral' ? 'brand' : overallTone) : 'brand'}
|
||||
label={totalHeld ? 'Overall' : 'No data'}
|
||||
accessibilityLabel={totalHeld ? `Overall attendance ${overall} percent` : 'No attendance data yet'}
|
||||
/>
|
||||
<View style={styles.heroCopy}>
|
||||
<AppText variant="heading3">{insight.title}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.heroMessage}>{insight.message}</AppText>
|
||||
{totalHeld ? <StatusPill
|
||||
label={overallTone === 'success' ? 'On track' : overallTone === 'warning' ? 'Close to target' : 'Below target'}
|
||||
tone={overallTone}
|
||||
icon={overallTone === 'success' ? 'shield-checkmark' : 'alert-circle'}
|
||||
style={styles.heroStatus}
|
||||
/> : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.stats} padding={0}>
|
||||
<Stat value={totalHeld} label="Classes held" />
|
||||
<View style={styles.statDivider} />
|
||||
<Stat value={totalAttended} label="Attended" />
|
||||
<View style={styles.statDivider} />
|
||||
<Stat value={totalMissed} label="Missed" />
|
||||
</Card>
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View>
|
||||
<AppText variant="heading2">By subject</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>Subjects needing attention appear first</AppText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{subjects.length === 0 ? <EmptyState icon="pie-chart-outline" title="No attendance yet" message="Add subjects and classes, then mark attendance from Today." /> : <Card padding={0} style={styles.subjectList}>
|
||||
{sortedSubjects.map((subject, index) => {
|
||||
const tone = subjectToneFor(subject.id, subject.color);
|
||||
const palette = colors.subject[tone];
|
||||
const state = attendanceTone(subject.percentage, subject.total);
|
||||
const atRisk = subject.total > 0 && state !== 'success';
|
||||
return <Pressable
|
||||
key={subject.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${subject.name}, ${subject.percentage} percent attendance, ${subject.attended} of ${subject.total} classes attended`}
|
||||
onPress={() => router.push(`/subjects/${subject.id}` as never)}
|
||||
style={({ pressed }) => [styles.subject, pressed && styles.subjectPressed]}>
|
||||
<SubjectBadge shortName={subject.shortName || subject.code.slice(0, 3)} tone={tone} />
|
||||
<View style={styles.subjectBody}>
|
||||
<View style={styles.subjectTop}>
|
||||
<View style={styles.subjectTitleWrap}>
|
||||
<AppText variant="title" numberOfLines={2}>{subject.name}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.subjectMeta}>{subject.code} · {subject.attended}/{subject.total} attended</AppText>
|
||||
</View>
|
||||
<AppText variant="title" color={atRisk ? colors.semantic[state].text : palette.accent} style={styles.percentage}>{subject.total ? `${subject.percentage}%` : '—'}</AppText>
|
||||
</View>
|
||||
<ProgressBar value={subject.total ? subject.percentage : 0} color={palette.accent} accessibilityLabel={`${subject.name} attendance`} style={styles.progress} />
|
||||
{atRisk ? <StatusPill label={state === 'warning' ? 'Close to target' : 'Needs attention'} tone={state} icon="alert-circle" style={styles.subjectStatus} /> : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.neutral.textMuted} />
|
||||
{index < sortedSubjects.length - 1 ? <View style={styles.subjectDivider} /> : null}
|
||||
</Pressable>;
|
||||
})}
|
||||
</Card>}
|
||||
</> : null}
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: number; label: string }) {
|
||||
return <View style={styles.stat}>
|
||||
<AppText variant="heading3" style={styles.tabular}>{value}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.statLabel}>{label}</AppText>
|
||||
</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
firstBlock: { marginTop: spacing[6] },
|
||||
loading: { minHeight: 144, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
hero: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'center', gap: spacing[6], borderRadius: 20 },
|
||||
heroCopy: { flex: 1 },
|
||||
heroMessage: { marginTop: spacing[2] },
|
||||
heroStatus: { marginTop: spacing[4] },
|
||||
stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
|
||||
stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
|
||||
statDivider: { width: 1, height: 36, backgroundColor: colors.neutral.divider },
|
||||
statLabel: { marginTop: spacing[1], textAlign: 'center' },
|
||||
tabular: { fontVariant: ['tabular-nums'] },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4] },
|
||||
sectionSub: { marginTop: spacing[1] },
|
||||
subjectList: { overflow: 'hidden' },
|
||||
subject: { minHeight: 100, flexDirection: 'row', alignItems: 'center', gap: spacing[3], padding: spacing[4] },
|
||||
subjectPressed: { backgroundColor: colors.brand.skySoft },
|
||||
subjectBody: { flex: 1 },
|
||||
subjectTop: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3] },
|
||||
subjectTitleWrap: { flex: 1 },
|
||||
subjectMeta: { marginTop: spacing[1] },
|
||||
percentage: { fontVariant: ['tabular-nums'] },
|
||||
progress: { marginTop: spacing[3] },
|
||||
subjectStatus: { marginTop: spacing[3] },
|
||||
subjectDivider: { position: 'absolute', left: 72, right: spacing[4], bottom: 0, height: 1, backgroundColor: colors.neutral.divider },
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,98 +1,313 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
import { AddClassModal } from '@/components/add-class-modal';
|
||||
import {
|
||||
AppHeader,
|
||||
AppText,
|
||||
BottomSheet,
|
||||
Button,
|
||||
Card,
|
||||
CurrentTimeIndicator,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
InlineBanner,
|
||||
RecessCard,
|
||||
ScheduleEventCard,
|
||||
Screen,
|
||||
WeekStrip,
|
||||
colors,
|
||||
radius,
|
||||
size,
|
||||
spacing,
|
||||
type WeekDay,
|
||||
} from '@/components/ui';
|
||||
import { addDays, currentMinutes, dateKey, formatDayHeading, mondayOfWeek, timeToMinutes } from '@/lib/date';
|
||||
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
type DayMarker = WeekDay['marker'];
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
type AttendanceSummary = { total: number; attended: number; percentage: number };
|
||||
|
||||
const emptySummary: AttendanceSummary = { total: 0, attended: 0, percentage: 0 };
|
||||
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
const daysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
|
||||
export default function TodayScreen() {
|
||||
const router = useRouter();
|
||||
const [activeDate, setActiveDate] = useState(() => new Date());
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date()));
|
||||
const [classes, setClasses] = useState<Session[]>([]);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [summary, setSummary] = useState<AttendanceSummary>(emptySummary);
|
||||
const [weekendSchedule, setWeekendSchedule] = useState(false);
|
||||
const [weekMarkers, setWeekMarkers] = useState<Record<string, DayMarker>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [addClassOpen, setAddClassOpen] = useState(false);
|
||||
const [selectedClass, setSelectedClass] = useState<Session | null>(null);
|
||||
|
||||
const today = new Date();
|
||||
const todayKey = dateKey(today);
|
||||
const selectedDateKey = dateKey(activeDate);
|
||||
const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
|
||||
const weekDates = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)), [weekStart]);
|
||||
|
||||
useFocusEffect(useCallback(() => {
|
||||
let active = true;
|
||||
collegeApi.profile().then((result) => {
|
||||
if (!active) return;
|
||||
setProfile(result);
|
||||
setWeekendSchedule(Boolean(result.weekendSchedule));
|
||||
if (!result.weekendSchedule) setActiveDate((current) => current.getDay() === 0 || current.getDay() === 6 ? mondayOfWeek(current) : current);
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, []));
|
||||
|
||||
const loadSchedule = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
collegeApi.schedule(selectedDateKey)
|
||||
.then(({ sessions }) => setClasses(sessions))
|
||||
.catch((error: Error) => { setClasses([]); setLoadError(error.message); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedDateKey]);
|
||||
|
||||
useEffect(() => { loadSchedule(); }, [loadSchedule, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
collegeApi.attendanceSummary().then(({ subjects }) => {
|
||||
const total = subjects.reduce((value, subject) => value + subject.total, 0);
|
||||
const attended = subjects.reduce((value, subject) => value + subject.attended, 0);
|
||||
setSummary({ total, attended, percentage: total ? Math.round(attended / total * 100) : 0 });
|
||||
}).catch(() => setSummary(emptySummary));
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all(weekDates.map((date) => collegeApi.schedule(dateKey(date)).then(({ sessions }) => ({ date, sessions })).catch(() => ({ date, sessions: [] as Session[] }))))
|
||||
.then((results) => {
|
||||
const markers: Record<string, DayMarker> = {};
|
||||
results.forEach(({ date, sessions }) => {
|
||||
const key = dateKey(date);
|
||||
if (key > todayKey) return;
|
||||
const held = sessions.filter((session) => session.status !== 'cancelled');
|
||||
if (!held.length) return;
|
||||
if (held.every((session) => session.status === 'attended')) markers[key] = 'success';
|
||||
else if (held.some((session) => session.status === 'absent')) markers[key] = 'danger';
|
||||
else if (held.some((session) => session.status === 'pending')) markers[key] = 'warning';
|
||||
else markers[key] = 'neutral';
|
||||
});
|
||||
setWeekMarkers(markers);
|
||||
});
|
||||
}, [refresh, todayKey, weekDates]);
|
||||
|
||||
const visibleDays: WeekDay[] = weekDates
|
||||
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
|
||||
.map((date) => ({ date, marker: weekMarkers[dateKey(date)] ?? 'none' }));
|
||||
|
||||
const updateStatus = (session: Session, status: AttendanceStatus) => {
|
||||
const previous = classes;
|
||||
setClasses((items) => items.map((item) => item.id === session.id ? { ...item, status } : item));
|
||||
collegeApi.markAttendance(session.id, status)
|
||||
.then(() => {
|
||||
if (Platform.OS !== 'web') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setClasses(previous);
|
||||
Alert.alert('Couldn’t save attendance', error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const removeClass = (session: Session) => {
|
||||
Alert.alert('Remove this class?', 'This removes only this occurrence. Your recurring timetable will not change.', [
|
||||
{ text: 'Keep class', style: 'cancel' },
|
||||
{ text: 'Remove', style: 'destructive', onPress: () => {
|
||||
setSelectedClass(null);
|
||||
setClasses((items) => items.filter((item) => item.id !== session.id));
|
||||
collegeApi.removeClass(session.id).then(() => setRefresh((value) => value + 1)).catch((error: Error) => {
|
||||
Alert.alert('Couldn’t remove class', error.message);
|
||||
setRefresh((value) => value + 1);
|
||||
});
|
||||
} },
|
||||
]);
|
||||
};
|
||||
|
||||
const isToday = selectedDateKey === todayKey;
|
||||
const insight = attendanceMessage(summary.percentage, summary.total);
|
||||
const insightTone = attendanceTone(summary.percentage, summary.total);
|
||||
const now = currentMinutes();
|
||||
const recessEnabled = Boolean(profile && profile.recessEnabled && timeToMinutes(profile.recessStart) >= 0 && timeToMinutes(profile.recessStart) < timeToMinutes(profile.recessEnd));
|
||||
const timelineItems = [
|
||||
...classes.map((item) => ({ type: 'class' as const, start: item.time, end: item.endTime, item })),
|
||||
...(recessEnabled ? [{ type: 'recess' as const, start: profile!.recessStart, end: profile!.recessEnd }] : []),
|
||||
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
|
||||
const activeTimelineIndex = isToday ? timelineItems.findIndex((item) => timeToMinutes(item.start) <= now && now < timeToMinutes(item.end)) : -1;
|
||||
const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1;
|
||||
const currentTimeLabel = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date());
|
||||
const pendingCount = classes.filter((item) => item.status === 'pending').length;
|
||||
|
||||
return <Screen scroll={false} contentContainerStyle={styles.content}>
|
||||
<View style={styles.stickyHeader}>
|
||||
<AppHeader title={new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'short', day: 'numeric' }).format(activeDate)} />
|
||||
|
||||
<View style={styles.dateActions}>
|
||||
{!isToday ? <Button label="Today" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
|
||||
<IconButton icon="calendar-outline" label="Open calendar" tone="sky" onPress={() => { setCalendarMonth(startOfMonth(activeDate)); setCalendarOpen(true); }} />
|
||||
</View>
|
||||
<WeekStrip days={visibleDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
|
||||
|
||||
{summary.total > 0 ? <InlineBanner title={insight.title} message={insight.message} tone={insightTone} style={styles.insight} /> : null}
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionCopy}>
|
||||
<AppText variant="heading2">{isToday ? 'Today’s classes' : `${new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate)}’s classes`}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSubtitle}>
|
||||
{classes.length ? `${classes.length} ${classes.length === 1 ? 'class' : 'classes'}${pendingCount ? ` · ${pendingCount} to mark` : ''}` : 'Your agenda for this date'}
|
||||
</AppText>
|
||||
</View>
|
||||
<IconButton icon="add" label="Add a class" onPress={() => setAddClassOpen(true)} />
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.classList} contentContainerStyle={styles.classListContent} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||
{loadError ? <InlineBanner title="Couldn’t load this day" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={loadSchedule} />} /> : null}
|
||||
{loading ? <Card tone="skySoft" style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading your classes…</AppText></Card> : null}
|
||||
{!loading && !loadError && classes.length === 0 && !recessEnabled ? <EmptyState icon="calendar-clear-outline" title="No classes scheduled" message="Enjoy the break, or add a one-off class for this date." action={<Button label="Add a class" variant="secondary" onPress={() => setAddClassOpen(true)} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />} /> : null}
|
||||
|
||||
{!loading && !loadError && timelineItems.length > 0 ? <View style={styles.timeline}>
|
||||
{timelineItems.map((entry, index) => {
|
||||
const isNow = index === activeTimelineIndex;
|
||||
const key = entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`;
|
||||
return <View key={key}>
|
||||
{index === insertionIndex ? <CurrentTimeIndicator time={currentTimeLabel} /> : null}
|
||||
<View style={styles.classRow}>
|
||||
<View style={styles.timeRail}>
|
||||
<AppText variant="label" style={styles.timeText}>{entry.start}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.timeText}>{entry.end}</AppText>
|
||||
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
|
||||
</View>
|
||||
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}–${entry.end}`} isNow={isNow} style={styles.event} /> : <ScheduleEventCard
|
||||
title={entry.item.title}
|
||||
kind={entry.item.code}
|
||||
classType={entry.item.classType}
|
||||
timeRange={`${entry.start}–${entry.end}`}
|
||||
room={entry.item.room}
|
||||
subjectTone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)}
|
||||
state={entry.item.status}
|
||||
isNow={isNow}
|
||||
style={styles.event}
|
||||
topAction={<IconButton icon="ellipsis-horizontal" label={`Options for ${entry.item.title}`} tone="ghost" onPress={() => setSelectedClass(entry.item)} />}
|
||||
footer={entry.item.status !== 'cancelled' ? <AttendanceActions tone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)} onAttended={() => updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null}
|
||||
/>}
|
||||
</View>
|
||||
</View>;
|
||||
})}
|
||||
{isToday && insertionIndex === -1 && activeTimelineIndex === -1 ? <CurrentTimeIndicator time={currentTimeLabel} /> : null}
|
||||
</View> : null}
|
||||
</ScrollView>
|
||||
|
||||
<AddClassModal visible={addClassOpen} date={selectedDateKey} onClose={() => setAddClassOpen(false)} onAdded={() => setRefresh((value) => value + 1)} />
|
||||
|
||||
<BottomSheet visible={Boolean(selectedClass)} title="Class options" onClose={() => setSelectedClass(null)}>
|
||||
{selectedClass ? <>
|
||||
<Card tone="skySoft" style={styles.selectedClassSummary}>
|
||||
<AppText variant="title">{selectedClass.title}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.sectionSubtitle}>{selectedClass.code} · {selectedClass.time}–{selectedClass.endTime} · {selectedClass.room}</AppText>
|
||||
</Card>
|
||||
<View style={styles.sheetButtons}>
|
||||
<Button label="View subject" variant="secondary" disabled={!selectedClass.subjectId} onPress={() => { const id = selectedClass.subjectId; setSelectedClass(null); if (id) router.push(`/subjects/${id}` as never); }} leading={<Ionicons name="book-outline" size={18} color={colors.brand.cobalt} />} />
|
||||
{selectedClass.status !== 'cancelled' ? <Button label="Cancel class" variant="ghost" onPress={() => { updateStatus(selectedClass, 'cancelled'); setSelectedClass(null); }} leading={<Ionicons name="remove-circle-outline" size={18} color={colors.semantic.danger.text} />} /> : null}
|
||||
<Button label="Remove from this day" variant="ghost" onPress={() => removeClass(selectedClass)} leading={<Ionicons name="trash-outline" size={18} color={colors.semantic.danger.text} />} />
|
||||
</View>
|
||||
</> : null}
|
||||
</BottomSheet>
|
||||
|
||||
<CalendarSheet
|
||||
visible={calendarOpen}
|
||||
month={calendarMonth}
|
||||
selectedDateKey={selectedDateKey}
|
||||
todayKey={todayKey}
|
||||
onMonthChange={setCalendarMonth}
|
||||
onClose={() => setCalendarOpen(false)}
|
||||
onSelect={(date) => { setActiveDate(date); setCalendarOpen(false); }}
|
||||
/>
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
function AttendanceActions({ tone, onAttended, onAbsent }: { tone: keyof typeof colors.subject; onAttended: () => void; onAbsent: () => void }) {
|
||||
const palette = colors.subject[tone];
|
||||
return <View style={styles.attendanceActions}>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Mark attended" onPress={onAttended} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
|
||||
<Ionicons name="checkmark" size={18} color={palette.accent} />
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Mark absent" onPress={onAbsent} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
|
||||
<Ionicons name="close" size={18} color={palette.accent} />
|
||||
</Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function CalendarSheet({ visible, month, selectedDateKey, todayKey, onMonthChange, onClose, onSelect }: { visible: boolean; month: Date; selectedDateKey: string; todayKey: string; onMonthChange: (date: Date) => void; onClose: () => void; onSelect: (date: Date) => void }) {
|
||||
const offset = (month.getDay() + 6) % 7;
|
||||
const dates = Array.from({ length: offset + daysInMonth(month) }, (_, index) => index < offset ? null : index - offset + 1);
|
||||
const weekdayLabels = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(addDays(mondayOfWeek(new Date()), index)));
|
||||
return <BottomSheet visible={visible} title="Choose a date" onClose={onClose}>
|
||||
<View style={styles.monthNavigation}>
|
||||
<IconButton icon="chevron-back" label="Previous month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() - 1, 1))} />
|
||||
<AppText variant="title">{new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(month)}</AppText>
|
||||
<IconButton icon="chevron-forward" label="Next month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() + 1, 1))} />
|
||||
</View>
|
||||
<View style={styles.calendarWeekdays}>{weekdayLabels.map((label, index) => <AppText key={`${label}-${index}`} variant="caption" color={colors.neutral.textMuted} style={styles.calendarWeekday}>{label}</AppText>)}</View>
|
||||
<View style={styles.calendarGrid}>{dates.map((day, index) => {
|
||||
if (!day) return <View key={`empty-${index}`} style={styles.calendarCell} />;
|
||||
const date = new Date(month.getFullYear(), month.getMonth(), day);
|
||||
const key = dateKey(date);
|
||||
const selected = key === selectedDateKey;
|
||||
const isToday = key === todayKey;
|
||||
return <Pressable key={key} accessibilityRole="button" accessibilityLabel={formatDayHeading(date)} accessibilityState={{ selected }} onPress={() => onSelect(date)} style={[styles.calendarCell, selected && styles.calendarSelected, isToday && !selected && styles.calendarToday]}>
|
||||
<AppText variant="label" color={selected ? colors.brand.ink : colors.neutral.textPrimary} style={styles.timeText}>{day}</AppText>
|
||||
</Pressable>;
|
||||
})}</View>
|
||||
<Button label="Go to today" variant="secondary" onPress={() => onSelect(new Date())} leading={<Ionicons name="locate-outline" size={18} color={colors.brand.cobalt} />} style={styles.calendarButton} />
|
||||
</BottomSheet>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
// Bottom padding belongs to the scrollable class content, not this fixed shell.
|
||||
content: { paddingTop: spacing[1], paddingBottom: 0 },
|
||||
|
||||
stickyHeader: { flexShrink: 0 },
|
||||
dateActions: { minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: spacing[1], marginBottom: spacing[3] },
|
||||
insight: { marginTop: spacing[5] },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
|
||||
sectionCopy: { flex: 1 },
|
||||
sectionSubtitle: { marginTop: spacing[1] },
|
||||
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
timeline: { gap: spacing[2] },
|
||||
classRow: { flexDirection: 'row', alignItems: 'stretch' },
|
||||
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
|
||||
timeText: { fontVariant: ['tabular-nums'] },
|
||||
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
|
||||
classList: { flex: 1, minHeight: 0 },
|
||||
classListContent: { paddingTop: spacing[4], paddingBottom: size.tabBar + spacing[6] },
|
||||
event: { flex: 1, marginBottom: spacing[2] },
|
||||
attendanceActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
|
||||
attendanceButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, borderWidth: 1, backgroundColor: 'rgba(255, 255, 255, 0.62)' },
|
||||
actionPressed: { opacity: 0.72 },
|
||||
sheetButtons: { gap: spacing[2], marginTop: spacing[5] },
|
||||
selectedClassSummary: { marginTop: spacing[2] },
|
||||
monthNavigation: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing[5] },
|
||||
calendarWeekdays: { flexDirection: 'row', marginBottom: spacing[2] },
|
||||
calendarWeekday: { width: '14.2857%', textAlign: 'center' },
|
||||
calendarGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
calendarCell: { width: '14.2857%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
|
||||
calendarSelected: { backgroundColor: colors.brand.coral },
|
||||
calendarToday: { borderWidth: 2, borderColor: colors.brand.cobalt },
|
||||
calendarButton: { marginTop: spacing[5] },
|
||||
});
|
||||
|
||||
1
app/(tabs)/settings.tsx
Normal file
1
app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../settings';
|
||||
161
app/(tabs)/timetable.tsx
Normal file
161
app/(tabs)/timetable.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { AddClassModal } from '@/components/add-class-modal';
|
||||
import {
|
||||
AppHeader,
|
||||
AppText,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
InlineBanner,
|
||||
RecessCard,
|
||||
ScheduleEventCard,
|
||||
Screen,
|
||||
WeekStrip,
|
||||
colors,
|
||||
spacing,
|
||||
type WeekDay,
|
||||
} from '@/components/ui';
|
||||
import { addDays, dateKey, formatDayHeading, mondayOfWeek } from '@/lib/date';
|
||||
import { subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi } from '@/lib/api';
|
||||
|
||||
type TimetableClass = Awaited<ReturnType<typeof collegeApi.timetable>>['classes'][number];
|
||||
|
||||
export default function TimetableScreen() {
|
||||
const router = useRouter();
|
||||
const [activeDate, setActiveDate] = useState(() => {
|
||||
const today = new Date();
|
||||
return today.getDay() === 0 || today.getDay() === 6 ? mondayOfWeek(today) : today;
|
||||
});
|
||||
const [schedule, setSchedule] = useState<TimetableClass[]>([]);
|
||||
const [weekendSchedule, setWeekendSchedule] = useState(false);
|
||||
const [recess, setRecess] = useState<{ enabled: boolean; start: string; end: string }>({ enabled: false, start: '13:00', end: '14:00' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [addClassOpen, setAddClassOpen] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const selectedDateKey = dateKey(activeDate);
|
||||
const todayKey = dateKey(new Date());
|
||||
const weekStart = useMemo(() => mondayOfWeek(activeDate), [activeDate]);
|
||||
const weekDays: WeekDay[] = useMemo(() => Array.from({ length: 7 }, (_, index) => addDays(weekStart, index))
|
||||
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
|
||||
.map((date) => ({ date })), [weekStart, weekendSchedule]);
|
||||
|
||||
useFocusEffect(useCallback(() => {
|
||||
let active = true;
|
||||
collegeApi.profile().then((profile) => {
|
||||
if (!active) return;
|
||||
setWeekendSchedule(Boolean(profile.weekendSchedule));
|
||||
setRecess({ enabled: Boolean(profile.recessEnabled), start: profile.recessStart, end: profile.recessEnd });
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, []));
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
collegeApi.timetable(selectedDateKey)
|
||||
.then(({ classes }) => setSchedule(classes))
|
||||
.catch((error: Error) => { setSchedule([]); setLoadError(error.message); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedDateKey]);
|
||||
|
||||
useEffect(() => { load(); }, [load, refresh]);
|
||||
|
||||
const moveWeek = (amount: number) => setActiveDate(addDays(activeDate, amount * 7));
|
||||
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate);
|
||||
const showRecess = recess.enabled && recess.start < recess.end;
|
||||
const timelineItems = [
|
||||
...schedule.map((item) => ({ type: 'class' as const, start: item.startTime, end: item.endTime, item })),
|
||||
...(showRecess ? [{ type: 'recess' as const, start: recess.start, end: recess.end }] : []),
|
||||
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
|
||||
|
||||
return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader title="Timetable" />
|
||||
|
||||
<View style={styles.weekToolbar}>
|
||||
<IconButton icon="chevron-back" label="Previous week" tone="ghost" onPress={() => moveWeek(-1)} />
|
||||
<View style={styles.weekCopy}>
|
||||
<AppText variant="label">Week of {new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(weekStart)}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted}>{new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(activeDate)}</AppText>
|
||||
</View>
|
||||
<View style={styles.toolbarActions}>
|
||||
{todayKey !== selectedDateKey ? <Button label="This week" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
|
||||
<IconButton icon="chevron-forward" label="Next week" tone="ghost" onPress={() => moveWeek(1)} />
|
||||
</View>
|
||||
</View>
|
||||
<WeekStrip days={weekDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionCopy}>
|
||||
<AppText variant="heading2">{weekday}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSub}>{formatDayHeading(activeDate)} · {schedule.length} {schedule.length === 1 ? 'class' : 'classes'}</AppText>
|
||||
</View>
|
||||
<IconButton icon="add" label={`Add a recurring class on ${weekday}`} onPress={() => setAddClassOpen(true)} />
|
||||
</View>
|
||||
|
||||
{loadError ? <InlineBanner title="Couldn’t load the timetable" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} /> : null}
|
||||
{loading ? <Card tone="skySoft" style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading timetable…</AppText></Card> : null}
|
||||
{!loading && !loadError && schedule.length === 0 && !showRecess ? <EmptyState icon="calendar-outline" title={`No classes on ${weekday}`} message="Add a recurring class to build this day’s timetable." action={<Button label="Add recurring class" variant="secondary" onPress={() => setAddClassOpen(true)} leading={<Ionicons name="add" size={18} color={colors.brand.cobalt} />} />} /> : null}
|
||||
|
||||
{!loading && !loadError && timelineItems.length > 0 ? <View style={styles.timeline}>
|
||||
{timelineItems.map((entry, index) => <View key={entry.type === 'class' ? `class-${entry.item.id}` : `recess-${entry.start}`} style={styles.classRow}>
|
||||
<View style={styles.timeRail}>
|
||||
<AppText variant="label" style={styles.tabular}>{entry.start}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.tabular}>{entry.end}</AppText>
|
||||
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
|
||||
</View>
|
||||
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}–${entry.end}`} style={styles.event} /> : <ScheduleEventCard
|
||||
title={entry.item.subjectName}
|
||||
kind={entry.item.code}
|
||||
classType={entry.item.classType}
|
||||
timeRange={`${entry.start}–${entry.end}`}
|
||||
room={entry.item.room}
|
||||
subjectTone={subjectToneFor(entry.item.subjectId, entry.item.color)}
|
||||
style={styles.event}
|
||||
onPress={() => router.push(`/subjects/${entry.item.subjectId}` as never)}
|
||||
/>}
|
||||
</View>)}
|
||||
</View> : null}
|
||||
|
||||
<InlineBanner
|
||||
title="Recurring changes start in the future"
|
||||
message="Past classes and attendance records stay unchanged. Make one-day changes from Today."
|
||||
tone="neutral"
|
||||
style={styles.note}
|
||||
/>
|
||||
|
||||
<AddClassModal
|
||||
visible={addClassOpen}
|
||||
regular
|
||||
date={selectedDateKey}
|
||||
weekday={activeDate.getDay()}
|
||||
onClose={() => setAddClassOpen(false)}
|
||||
onAdded={() => setRefresh((value) => value + 1)}
|
||||
/>
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
weekToolbar: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
|
||||
weekCopy: { flex: 1, alignItems: 'center' },
|
||||
toolbarActions: { flexDirection: 'row', alignItems: 'center' },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
|
||||
sectionCopy: { flex: 1 },
|
||||
sectionSub: { marginTop: spacing[1] },
|
||||
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
timeline: { gap: spacing[2] },
|
||||
classRow: { flexDirection: 'row', alignItems: 'stretch' },
|
||||
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
|
||||
tabular: { fontVariant: ['tabular-nums'] },
|
||||
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
|
||||
event: { flex: 1, marginBottom: spacing[2] },
|
||||
note: { marginTop: spacing[6] },
|
||||
});
|
||||
@@ -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
142
app/account.tsx
Normal 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="Couldn’t 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 },
|
||||
});
|
||||
@@ -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
165
app/onboarding.tsx
Normal 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. We’ll 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}>They’ll keep the same color throughout your timetable and attendance views.</AppText>
|
||||
</View>
|
||||
|
||||
{formError ? <InlineBanner title="Couldn’t 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
184
app/settings.tsx
Normal 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="Couldn’t 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
213
app/subjects/[id].tsx
Normal 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="Couldn’t 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
9
backend/Dockerfile
Normal 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
50
backend/README.md
Normal 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
250
backend/server.mjs
Normal 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}`));
|
||||
179
components/add-class-modal.tsx
Normal file
179
components/add-class-modal.tsx
Normal 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:00–14: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="Couldn’t 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] },
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
43
components/ui/app-header.tsx
Normal file
43
components/ui/app-header.tsx
Normal 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] },
|
||||
});
|
||||
12
components/ui/app-text.tsx
Normal file
12
components/ui/app-text.tsx
Normal 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]} />;
|
||||
}
|
||||
58
components/ui/attendance-ring.tsx
Normal file
58
components/ui/attendance-ring.tsx
Normal 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'] },
|
||||
});
|
||||
47
components/ui/bottom-sheet.tsx
Normal file
47
components/ui/bottom-sheet.tsx
Normal 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 },
|
||||
});
|
||||
56
components/ui/bottom-tab-bar.tsx
Normal file
56
components/ui/bottom-tab-bar.tsx
Normal 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
65
components/ui/button.tsx
Normal 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
23
components/ui/card.tsx
Normal 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 },
|
||||
});
|
||||
20
components/ui/current-time-indicator.tsx
Normal file
20
components/ui/current-time-indicator.tsx
Normal 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 },
|
||||
});
|
||||
30
components/ui/empty-state.tsx
Normal file
30
components/ui/empty-state.tsx
Normal 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' },
|
||||
});
|
||||
44
components/ui/form-field.tsx
Normal file
44
components/ui/form-field.tsx
Normal 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] },
|
||||
});
|
||||
41
components/ui/icon-button.tsx
Normal file
41
components/ui/icon-button.tsx
Normal 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
22
components/ui/index.ts
Normal 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';
|
||||
35
components/ui/inline-banner.tsx
Normal file
35
components/ui/inline-banner.tsx
Normal 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' },
|
||||
});
|
||||
27
components/ui/progress-bar.tsx
Normal file
27
components/ui/progress-bar.tsx
Normal 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%' },
|
||||
});
|
||||
36
components/ui/recess-card.tsx
Normal file
36
components/ui/recess-card.tsx
Normal 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'] },
|
||||
});
|
||||
81
components/ui/schedule-event-card.tsx
Normal file
81
components/ui/schedule-event-card.tsx
Normal 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
40
components/ui/screen.tsx
Normal 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 },
|
||||
});
|
||||
23
components/ui/status-pill.tsx
Normal file
23
components/ui/status-pill.tsx
Normal 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] },
|
||||
});
|
||||
19
components/ui/subject-badge.tsx
Normal file
19
components/ui/subject-badge.tsx
Normal 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
87
components/ui/tokens.ts
Normal 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;
|
||||
56
components/ui/week-strip.tsx
Normal file
56
components/ui/week-strip.tsx
Normal 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 },
|
||||
});
|
||||
@@ -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
BIN
designs/37400450.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
BIN
designs/37400451.png
Normal file
BIN
designs/37400451.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
1003
designs/DESIGN_SYSTEM.md
Normal file
1003
designs/DESIGN_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
223
designs/design-tokens.json
Normal file
223
designs/design-tokens.json
Normal 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
BIN
designs/timeline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal 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
26
docker/nginx.conf
Normal 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
36
lib/api.ts
Normal 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
34
lib/date.ts
Normal 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
31
lib/design.ts
Normal 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: 'You’re on track', message: `${percentage}% overall · Keep it above ${threshold}%.` };
|
||||
if (percentage >= threshold) return { title: 'You’re 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
164
package-lock.json
generated
@@ -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",
|
||||
|
||||
13
package.json
13
package.json
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user