Files
clg-kit/app/subjects/[id].tsx

214 lines
11 KiB
TypeScript
Raw Blame History

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