Update app and ignore local artifacts
This commit is contained in:
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user