Update app and ignore local artifacts

This commit is contained in:
2026-09-04 12:59:28 +05:30
parent 8120ebb927
commit e6884a148b
56 changed files with 4419 additions and 370 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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