feat: redesign day picker with expandable calendar, status markers & Playwright E2E testing
- Replace modal calendar with inline expandable calendar widget integrated into the day picker. - Add status indicator dots (attended/absent/pending) to month calendar grid matching day picker. - Disable and gray out weekend days in calendar when weekend schedule is turned off. - Redesign extend bar with integrated handle that aligns with the palette. - Remove start-to-end timing on Today screen's class time rail, showing only start time. - Implement Playwright E2E and visual testing suite with multi-screen capture scripts. - Add cascade DELETE endpoints for subjects and versioned recurring timetable entries.
This commit is contained in:
@@ -2,7 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from 'react-native';
|
||||
|
||||
import {
|
||||
AppHeader,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
InlineBanner,
|
||||
ProgressBar,
|
||||
Screen,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
colors,
|
||||
spacing,
|
||||
} from '@/components/ui';
|
||||
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { attendanceMessage, attendanceTone, subjectShortLabel, subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi } from '@/lib/api';
|
||||
|
||||
type Subject = Awaited<ReturnType<typeof collegeApi.attendanceSummary>>['subjects'][number];
|
||||
@@ -54,7 +55,21 @@ export default function AttendanceScreen() {
|
||||
});
|
||||
|
||||
return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader title="Attendance" />
|
||||
<AppHeader
|
||||
title="Attendance"
|
||||
style={styles.header}
|
||||
trailing={
|
||||
<IconButton
|
||||
icon="information-circle-outline"
|
||||
label="Attendance calculation rules"
|
||||
tone="ghost"
|
||||
onPress={() => Alert.alert(
|
||||
'Attendance rules',
|
||||
'• Overall attendance is calculated from classes that were marked attended vs. total classes held.\n\n• Cancelled classes are automatically excluded from calculations.\n\n• Target threshold is 75%.'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? <InlineBanner title="Couldn’t load attendance" message={loadError} tone="danger" action={<Button label="Retry" size="compact" variant="ghost" fullWidth={false} onPress={load} />} style={styles.firstBlock} /> : null}
|
||||
{loading ? <Card tone="skySoft" style={[styles.loading, styles.firstBlock]}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Calculating attendance…</AppText></Card> : null}
|
||||
@@ -106,7 +121,7 @@ export default function AttendanceScreen() {
|
||||
accessibilityLabel={`${subject.name}, ${subject.percentage} percent attendance, ${subject.attended} of ${subject.total} classes attended`}
|
||||
onPress={() => router.push(`/subjects/${subject.id}` as never)}
|
||||
style={({ pressed }) => [styles.subject, pressed && styles.subjectPressed]}>
|
||||
<SubjectBadge shortName={subject.shortName || subject.code.slice(0, 3)} tone={tone} />
|
||||
<SubjectBadge shortName={subjectShortLabel(subject.name, subject.code, subject.shortName)} tone={tone} />
|
||||
<View style={styles.subjectBody}>
|
||||
<View style={styles.subjectTop}>
|
||||
<View style={styles.subjectTitleWrap}>
|
||||
@@ -136,9 +151,10 @@ function Stat({ value, label }: { value: number; label: string }) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
firstBlock: { marginTop: spacing[6] },
|
||||
header: { paddingTop: spacing[2], minHeight: 0 },
|
||||
firstBlock: { marginTop: spacing[4] },
|
||||
loading: { minHeight: 144, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
hero: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'center', gap: spacing[6], borderRadius: 20 },
|
||||
hero: { marginTop: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[6], borderRadius: 20, borderCurve: 'continuous' },
|
||||
heroCopy: { flex: 1 },
|
||||
heroMessage: { marginTop: spacing[2] },
|
||||
heroStatus: { marginTop: spacing[4] },
|
||||
|
||||
@@ -25,7 +25,17 @@ import {
|
||||
spacing,
|
||||
type WeekDay,
|
||||
} from '@/components/ui';
|
||||
import { addDays, currentMinutes, dateKey, formatDayHeading, mondayOfWeek, timeToMinutes } from '@/lib/date';
|
||||
import {
|
||||
addDays,
|
||||
currentMinutes,
|
||||
dateKey,
|
||||
formatCurrentTime,
|
||||
formatDayHeading,
|
||||
formatHeaderDate,
|
||||
formatMonthYear,
|
||||
mondayOfWeek,
|
||||
timeToMinutes,
|
||||
} from '@/lib/date';
|
||||
import { attendanceMessage, attendanceTone, subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi, type AttendanceStatus, type Profile, type Session } from '@/lib/api';
|
||||
|
||||
@@ -46,10 +56,11 @@ export default function TodayScreen() {
|
||||
const [summary, setSummary] = useState<AttendanceSummary>(emptySummary);
|
||||
const [weekendSchedule, setWeekendSchedule] = useState(false);
|
||||
const [weekMarkers, setWeekMarkers] = useState<Record<string, DayMarker>>({});
|
||||
const [monthMarkers, setMonthMarkers] = useState<Record<string, DayMarker>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [calendarExpanded, setCalendarExpanded] = useState(false);
|
||||
const [addClassOpen, setAddClassOpen] = useState(false);
|
||||
const [selectedClass, setSelectedClass] = useState<Session | null>(null);
|
||||
|
||||
@@ -111,12 +122,20 @@ export default function TodayScreen() {
|
||||
.filter((date) => weekendSchedule || (date.getDay() !== 0 && date.getDay() !== 6))
|
||||
.map((date) => ({ date, marker: weekMarkers[dateKey(date)] ?? 'none' }));
|
||||
|
||||
const calendarMonthKey = `${calendarMonth.getFullYear()}-${String(calendarMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||
useEffect(() => {
|
||||
collegeApi.attendanceMarkers(calendarMonthKey)
|
||||
.then(({ markers }) => setMonthMarkers(markers))
|
||||
.catch(() => setMonthMarkers({}));
|
||||
}, [calendarMonthKey, refresh]);
|
||||
|
||||
const updateStatus = (session: Session, status: AttendanceStatus) => {
|
||||
const previous = classes;
|
||||
setClasses((items) => items.map((item) => item.id === session.id ? { ...item, status } : item));
|
||||
collegeApi.markAttendance(session.id, status)
|
||||
.then(() => {
|
||||
if (Platform.OS !== 'web') void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setRefresh((v) => v + 1);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setClasses(previous);
|
||||
@@ -149,24 +168,63 @@ export default function TodayScreen() {
|
||||
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
|
||||
const activeTimelineIndex = isToday ? timelineItems.findIndex((item) => timeToMinutes(item.start) <= now && now < timeToMinutes(item.end)) : -1;
|
||||
const insertionIndex = isToday ? (activeTimelineIndex >= 0 ? activeTimelineIndex : timelineItems.findIndex((item) => timeToMinutes(item.start) > now)) : -1;
|
||||
const currentTimeLabel = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date());
|
||||
const currentTimeLabel = formatCurrentTime();
|
||||
const pendingCount = classes.filter((item) => item.status === 'pending').length;
|
||||
|
||||
return <Screen scroll={false} contentContainerStyle={styles.content}>
|
||||
<View style={styles.stickyHeader}>
|
||||
<AppHeader title={new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'short', day: 'numeric' }).format(activeDate)} />
|
||||
|
||||
<View style={styles.dateActions}>
|
||||
{!isToday ? <Button label="Today" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
|
||||
<IconButton icon="calendar-outline" label="Open calendar" tone="sky" onPress={() => { setCalendarMonth(startOfMonth(activeDate)); setCalendarOpen(true); }} />
|
||||
<View style={styles.headerRow}>
|
||||
<AppHeader
|
||||
title={formatHeaderDate(activeDate)}
|
||||
style={styles.headerTitle}
|
||||
/>
|
||||
{profile?.name ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open account profile"
|
||||
onPress={() => router.push('/account' as never)}
|
||||
style={({ pressed }) => [styles.avatar, pressed && styles.avatarPressed]}>
|
||||
<AppText variant="label" color={colors.brand.cobalt}>{profile.initials || '?'}</AppText>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.pickerContainer}>
|
||||
{calendarExpanded ? (
|
||||
<InlineCalendarWidget
|
||||
month={calendarMonth}
|
||||
selectedDateKey={selectedDateKey}
|
||||
todayKey={todayKey}
|
||||
markers={monthMarkers}
|
||||
allowWeekends={weekendSchedule}
|
||||
onMonthChange={setCalendarMonth}
|
||||
onSelect={(date) => {
|
||||
setActiveDate(date);
|
||||
setCalendarExpanded(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<WeekStrip days={visibleDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} style={styles.weekStrip} />
|
||||
)}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={calendarExpanded ? 'Collapse calendar' : 'Expand calendar'}
|
||||
onPress={() => {
|
||||
if (!calendarExpanded) {
|
||||
setCalendarMonth(startOfMonth(activeDate));
|
||||
}
|
||||
setCalendarExpanded((v) => !v);
|
||||
}}
|
||||
style={({ pressed }) => [styles.extendBar, pressed && styles.extendBarPressed]}>
|
||||
<View style={styles.extendHandle} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<WeekStrip days={visibleDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
|
||||
|
||||
{summary.total > 0 ? <InlineBanner title={insight.title} message={insight.message} tone={insightTone} style={styles.insight} /> : null}
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionCopy}>
|
||||
<AppText variant="heading2">{isToday ? 'Today’s classes' : `${new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate)}’s classes`}</AppText>
|
||||
<AppText variant="heading2">{isToday ? 'Today’s classes' : `${formatDayHeading(activeDate).split(',')[0]}’s classes`}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textMuted} style={styles.sectionSubtitle}>
|
||||
{classes.length ? `${classes.length} ${classes.length === 1 ? 'class' : 'classes'}${pendingCount ? ` · ${pendingCount} to mark` : ''}` : 'Your agenda for this date'}
|
||||
</AppText>
|
||||
@@ -190,7 +248,6 @@ export default function TodayScreen() {
|
||||
<View style={styles.classRow}>
|
||||
<View style={styles.timeRail}>
|
||||
<AppText variant="label" style={styles.timeText}>{entry.start}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted} style={styles.timeText}>{entry.end}</AppText>
|
||||
{index < timelineItems.length - 1 ? <View style={styles.railLine} /> : null}
|
||||
</View>
|
||||
{entry.type === 'recess' ? <RecessCard timeRange={`${entry.start}–${entry.end}`} isNow={isNow} style={styles.event} /> : <ScheduleEventCard
|
||||
@@ -204,7 +261,7 @@ export default function TodayScreen() {
|
||||
isNow={isNow}
|
||||
style={styles.event}
|
||||
topAction={<IconButton icon="ellipsis-horizontal" label={`Options for ${entry.item.title}`} tone="ghost" onPress={() => setSelectedClass(entry.item)} />}
|
||||
footer={entry.item.status !== 'cancelled' ? <AttendanceActions tone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)} onAttended={() => updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null}
|
||||
footer={entry.item.status !== 'cancelled' ? <AttendanceActions tone={subjectToneFor(entry.item.subjectId || entry.item.code, entry.item.color)} status={entry.item.status} onAttended={() => updateStatus(entry.item, 'attended')} onAbsent={() => updateStatus(entry.item, 'absent')} /> : null}
|
||||
/>}
|
||||
</View>
|
||||
</View>;
|
||||
@@ -228,54 +285,140 @@ export default function TodayScreen() {
|
||||
</View>
|
||||
</> : null}
|
||||
</BottomSheet>
|
||||
|
||||
<CalendarSheet
|
||||
visible={calendarOpen}
|
||||
month={calendarMonth}
|
||||
selectedDateKey={selectedDateKey}
|
||||
todayKey={todayKey}
|
||||
onMonthChange={setCalendarMonth}
|
||||
onClose={() => setCalendarOpen(false)}
|
||||
onSelect={(date) => { setActiveDate(date); setCalendarOpen(false); }}
|
||||
/>
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
function AttendanceActions({ tone, onAttended, onAbsent }: { tone: keyof typeof colors.subject; onAttended: () => void; onAbsent: () => void }) {
|
||||
function AttendanceActions({ tone, status, onAttended, onAbsent }: { tone: keyof typeof colors.subject; status?: AttendanceStatus; onAttended: () => void; onAbsent: () => void }) {
|
||||
const palette = colors.subject[tone];
|
||||
const isAttended = status === 'attended';
|
||||
const isAbsent = status === 'absent';
|
||||
return <View style={styles.attendanceActions}>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Mark attended" onPress={onAttended} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
|
||||
<Ionicons name="checkmark" size={18} color={palette.accent} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Mark attended"
|
||||
onPress={onAttended}
|
||||
style={({ pressed }) => [
|
||||
styles.attendanceButton,
|
||||
{ borderColor: isAttended ? colors.semantic.success.solid : palette.accent },
|
||||
isAttended && { backgroundColor: colors.semantic.success.solid },
|
||||
pressed && styles.actionPressed,
|
||||
]}>
|
||||
<Ionicons name="checkmark" size={18} color={isAttended ? colors.neutral.surface : palette.accent} />
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Mark absent" onPress={onAbsent} style={({ pressed }) => [styles.attendanceButton, { borderColor: palette.accent }, pressed && styles.actionPressed]}>
|
||||
<Ionicons name="close" size={18} color={palette.accent} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Mark absent"
|
||||
onPress={onAbsent}
|
||||
style={({ pressed }) => [
|
||||
styles.attendanceButton,
|
||||
{ borderColor: isAbsent ? colors.semantic.danger.solid : palette.accent },
|
||||
isAbsent && { backgroundColor: colors.semantic.danger.solid },
|
||||
pressed && styles.actionPressed,
|
||||
]}>
|
||||
<Ionicons name="close" size={18} color={isAbsent ? colors.neutral.surface : palette.accent} />
|
||||
</Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function CalendarSheet({ visible, month, selectedDateKey, todayKey, onMonthChange, onClose, onSelect }: { visible: boolean; month: Date; selectedDateKey: string; todayKey: string; onMonthChange: (date: Date) => void; onClose: () => void; onSelect: (date: Date) => void }) {
|
||||
const markerColorMap = {
|
||||
none: 'transparent',
|
||||
success: colors.semantic.success.solid,
|
||||
warning: colors.semantic.warning.solid,
|
||||
danger: colors.semantic.danger.solid,
|
||||
neutral: colors.neutral.textMuted,
|
||||
} as const;
|
||||
|
||||
function InlineCalendarWidget({
|
||||
month,
|
||||
selectedDateKey,
|
||||
todayKey,
|
||||
markers = {},
|
||||
allowWeekends = false,
|
||||
onMonthChange,
|
||||
onSelect,
|
||||
}: {
|
||||
month: Date;
|
||||
selectedDateKey: string;
|
||||
todayKey: string;
|
||||
markers?: Record<string, DayMarker>;
|
||||
allowWeekends?: boolean;
|
||||
onMonthChange: (date: Date) => void;
|
||||
onSelect: (date: Date) => void;
|
||||
}) {
|
||||
const offset = (month.getDay() + 6) % 7;
|
||||
const dates = Array.from({ length: offset + daysInMonth(month) }, (_, index) => index < offset ? null : index - offset + 1);
|
||||
const weekdayLabels = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(addDays(mondayOfWeek(new Date()), index)));
|
||||
return <BottomSheet visible={visible} title="Choose a date" onClose={onClose}>
|
||||
<View style={styles.monthNavigation}>
|
||||
<IconButton icon="chevron-back" label="Previous month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() - 1, 1))} />
|
||||
<AppText variant="title">{new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(month)}</AppText>
|
||||
<IconButton icon="chevron-forward" label="Next month" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() + 1, 1))} />
|
||||
const dates = Array.from({ length: offset + daysInMonth(month) }, (_, index) => (index < offset ? null : index - offset + 1));
|
||||
const weekdayLabels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
return (
|
||||
<View style={styles.calendarWidget}>
|
||||
<View style={styles.monthNavigation}>
|
||||
<IconButton icon="chevron-back" label="Previous month" tone="ghost" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() - 1, 1))} />
|
||||
<AppText variant="title">{formatMonthYear(month)}</AppText>
|
||||
<IconButton icon="chevron-forward" label="Next month" tone="ghost" onPress={() => onMonthChange(new Date(month.getFullYear(), month.getMonth() + 1, 1))} />
|
||||
</View>
|
||||
<View style={styles.calendarWeekdays}>
|
||||
{weekdayLabels.map((label, index) => {
|
||||
const isWeekendCol = index >= 5;
|
||||
const disabledCol = isWeekendCol && !allowWeekends;
|
||||
return (
|
||||
<AppText
|
||||
key={`${label}-${index}`}
|
||||
variant="caption"
|
||||
color={disabledCol ? colors.neutral.textDisabled : colors.neutral.textMuted}
|
||||
style={[styles.calendarWeekday, disabledCol && styles.calendarDisabledText]}>
|
||||
{label}
|
||||
</AppText>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.calendarGrid}>
|
||||
{dates.map((day, index) => {
|
||||
if (!day) return <View key={`empty-${index}`} style={styles.calendarCell} />;
|
||||
const date = new Date(month.getFullYear(), month.getMonth(), day);
|
||||
const key = dateKey(date);
|
||||
const dayOfWeek = date.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
const isDisabled = isWeekend && !allowWeekends;
|
||||
const selected = key === selectedDateKey;
|
||||
const isToday = key === todayKey;
|
||||
const marker = markers[key] ?? 'none';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={formatDayHeading(date)}
|
||||
accessibilityState={{ selected, disabled: isDisabled }}
|
||||
disabled={isDisabled}
|
||||
onPress={() => onSelect(date)}
|
||||
style={({ pressed }) => [
|
||||
styles.calendarCell,
|
||||
selected && styles.calendarSelected,
|
||||
isToday && !selected && styles.calendarToday,
|
||||
isDisabled && styles.calendarCellDisabled,
|
||||
pressed && !isDisabled && styles.actionPressed,
|
||||
]}>
|
||||
<AppText
|
||||
variant="label"
|
||||
color={selected ? colors.brand.ink : isDisabled ? colors.neutral.textDisabled : colors.neutral.textPrimary}
|
||||
style={[styles.timeText, isDisabled && styles.calendarDisabledText]}>
|
||||
{day}
|
||||
</AppText>
|
||||
<View style={styles.indicatorRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
isToday && !selected && styles.todayDot,
|
||||
marker !== 'none' && !isDisabled && { backgroundColor: markerColorMap[marker] },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.calendarWeekdays}>{weekdayLabels.map((label, index) => <AppText key={`${label}-${index}`} variant="caption" color={colors.neutral.textMuted} style={styles.calendarWeekday}>{label}</AppText>)}</View>
|
||||
<View style={styles.calendarGrid}>{dates.map((day, index) => {
|
||||
if (!day) return <View key={`empty-${index}`} style={styles.calendarCell} />;
|
||||
const date = new Date(month.getFullYear(), month.getMonth(), day);
|
||||
const key = dateKey(date);
|
||||
const selected = key === selectedDateKey;
|
||||
const isToday = key === todayKey;
|
||||
return <Pressable key={key} accessibilityRole="button" accessibilityLabel={formatDayHeading(date)} accessibilityState={{ selected }} onPress={() => onSelect(date)} style={[styles.calendarCell, selected && styles.calendarSelected, isToday && !selected && styles.calendarToday]}>
|
||||
<AppText variant="label" color={selected ? colors.brand.ink : colors.neutral.textPrimary} style={styles.timeText}>{day}</AppText>
|
||||
</Pressable>;
|
||||
})}</View>
|
||||
<Button label="Go to today" variant="secondary" onPress={() => onSelect(new Date())} leading={<Ionicons name="locate-outline" size={18} color={colors.brand.cobalt} />} style={styles.calendarButton} />
|
||||
</BottomSheet>;
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -283,17 +426,62 @@ const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[1], paddingBottom: 0 },
|
||||
|
||||
stickyHeader: { flexShrink: 0 },
|
||||
dateActions: { minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: spacing[1], marginBottom: spacing[3] },
|
||||
insight: { marginTop: spacing[5] },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: spacing[2], marginBottom: spacing[3] },
|
||||
headerTitle: { flex: 1, minHeight: 0 },
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing[2], paddingTop: spacing[1] },
|
||||
avatar: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', backgroundColor: colors.brand.cobaltSoft, alignItems: 'center', justifyContent: 'center' },
|
||||
avatarPressed: { opacity: 0.78 },
|
||||
pickerContainer: {
|
||||
marginTop: spacing[1],
|
||||
borderRadius: radius.feature,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.brand.skySoft,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2F0FA',
|
||||
},
|
||||
weekStrip: {
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
calendarWidget: {
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing[2],
|
||||
paddingTop: spacing[3],
|
||||
paddingBottom: spacing[1],
|
||||
},
|
||||
extendBar: {
|
||||
height: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E2EEF8',
|
||||
},
|
||||
extendHandle: {
|
||||
width: 36,
|
||||
height: 3.5,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.neutral.textDisabled,
|
||||
opacity: 0.7,
|
||||
},
|
||||
extendBarPressed: { opacity: 0.6 },
|
||||
calendarCellDisabled: { opacity: 0.28 },
|
||||
calendarDisabledText: { textDecorationLine: 'none' },
|
||||
indicatorRow: { height: 5, marginTop: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
dot: { width: 4, height: 4, borderRadius: 2, backgroundColor: 'transparent' },
|
||||
todayDot: { width: 4.5, height: 4.5, borderRadius: 2.5, backgroundColor: colors.brand.cobalt },
|
||||
insight: { marginTop: spacing[4] },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
|
||||
sectionCopy: { flex: 1 },
|
||||
sectionSubtitle: { marginTop: spacing[1] },
|
||||
loading: { minHeight: 112, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
timeline: { gap: spacing[2] },
|
||||
classRow: { flexDirection: 'row', alignItems: 'stretch' },
|
||||
timeRail: { width: 54, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
|
||||
timeText: { fontVariant: ['tabular-nums'] },
|
||||
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
|
||||
timeRail: { width: 50, alignItems: 'flex-start', paddingTop: spacing[4], position: 'relative' },
|
||||
timeText: { fontVariant: ['tabular-nums'], fontSize: 13, lineHeight: 17, fontWeight: '700', color: colors.neutral.textSecondary },
|
||||
railLine: { position: 'absolute', top: 38, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
|
||||
classList: { flex: 1, minHeight: 0 },
|
||||
classListContent: { paddingTop: spacing[4], paddingBottom: size.tabBar + spacing[6] },
|
||||
event: { flex: 1, marginBottom: spacing[2] },
|
||||
@@ -306,7 +494,7 @@ const styles = StyleSheet.create({
|
||||
calendarWeekdays: { flexDirection: 'row', marginBottom: spacing[2] },
|
||||
calendarWeekday: { width: '14.2857%', textAlign: 'center' },
|
||||
calendarGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
calendarCell: { width: '14.2857%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control },
|
||||
calendarCell: { width: '14.2857%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.control, borderCurve: 'continuous' },
|
||||
calendarSelected: { backgroundColor: colors.brand.coral },
|
||||
calendarToday: { borderWidth: 2, borderColor: colors.brand.cobalt },
|
||||
calendarButton: { marginTop: spacing[5] },
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { AddClassModal } from '@/components/add-class-modal';
|
||||
import {
|
||||
AppHeader,
|
||||
AppText,
|
||||
BottomSheet,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
@@ -20,7 +21,15 @@ import {
|
||||
spacing,
|
||||
type WeekDay,
|
||||
} from '@/components/ui';
|
||||
import { addDays, dateKey, formatDayHeading, mondayOfWeek } from '@/lib/date';
|
||||
import {
|
||||
addDays,
|
||||
dateKey,
|
||||
formatDayHeading,
|
||||
formatMonthShortDay,
|
||||
formatWeekdayLong,
|
||||
formatYear,
|
||||
mondayOfWeek,
|
||||
} from '@/lib/date';
|
||||
import { subjectToneFor } from '@/lib/design';
|
||||
import { collegeApi } from '@/lib/api';
|
||||
|
||||
@@ -38,6 +47,7 @@ export default function TimetableScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [addClassOpen, setAddClassOpen] = useState(false);
|
||||
const [selectedTimetableClass, setSelectedTimetableClass] = useState<TimetableClass | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const selectedDateKey = dateKey(activeDate);
|
||||
@@ -69,7 +79,7 @@ export default function TimetableScreen() {
|
||||
useEffect(() => { load(); }, [load, refresh]);
|
||||
|
||||
const moveWeek = (amount: number) => setActiveDate(addDays(activeDate, amount * 7));
|
||||
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(activeDate);
|
||||
const weekday = formatWeekdayLong(activeDate);
|
||||
const showRecess = recess.enabled && recess.start < recess.end;
|
||||
const timelineItems = [
|
||||
...schedule.map((item) => ({ type: 'class' as const, start: item.startTime, end: item.endTime, item })),
|
||||
@@ -77,20 +87,20 @@ export default function TimetableScreen() {
|
||||
].sort((left, right) => left.start.localeCompare(right.start) || (left.type === 'recess' ? -1 : 1));
|
||||
|
||||
return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader title="Timetable" />
|
||||
<AppHeader title="Timetable" style={styles.header} />
|
||||
|
||||
<View style={styles.weekToolbar}>
|
||||
<IconButton icon="chevron-back" label="Previous week" tone="ghost" onPress={() => moveWeek(-1)} />
|
||||
<View style={styles.weekCopy}>
|
||||
<AppText variant="label">Week of {new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(weekStart)}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted}>{new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(activeDate)}</AppText>
|
||||
<AppText variant="label">Week of {formatMonthShortDay(weekStart)}</AppText>
|
||||
<AppText variant="caption" color={colors.neutral.textMuted}>{formatYear(activeDate)}</AppText>
|
||||
</View>
|
||||
<View style={styles.toolbarActions}>
|
||||
{todayKey !== selectedDateKey ? <Button label="This week" variant="ghost" size="compact" fullWidth={false} onPress={() => setActiveDate(new Date())} /> : null}
|
||||
<IconButton icon="chevron-forward" label="Next week" tone="ghost" onPress={() => moveWeek(1)} />
|
||||
</View>
|
||||
</View>
|
||||
<WeekStrip days={weekDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} />
|
||||
<WeekStrip days={weekDays} selectedDateKey={selectedDateKey} todayDateKey={todayKey} onSelect={setActiveDate} style={styles.weekStrip} />
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionCopy}>
|
||||
@@ -119,6 +129,7 @@ export default function TimetableScreen() {
|
||||
room={entry.item.room}
|
||||
subjectTone={subjectToneFor(entry.item.subjectId, entry.item.color)}
|
||||
style={styles.event}
|
||||
topAction={<IconButton icon="ellipsis-horizontal" label={`Options for ${entry.item.subjectName}`} tone="ghost" onPress={() => setSelectedTimetableClass(entry.item)} />}
|
||||
onPress={() => router.push(`/subjects/${entry.item.subjectId}` as never)}
|
||||
/>}
|
||||
</View>)}
|
||||
@@ -139,14 +150,69 @@ export default function TimetableScreen() {
|
||||
onClose={() => setAddClassOpen(false)}
|
||||
onAdded={() => setRefresh((value) => value + 1)}
|
||||
/>
|
||||
|
||||
<BottomSheet
|
||||
visible={Boolean(selectedTimetableClass)}
|
||||
title="Recurring class options"
|
||||
onClose={() => setSelectedTimetableClass(null)}>
|
||||
{selectedTimetableClass ? <>
|
||||
<Card tone="skySoft" style={styles.selectedClassSummary}>
|
||||
<AppText variant="title">{selectedTimetableClass.subjectName}</AppText>
|
||||
<AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.sectionSub}>
|
||||
{selectedTimetableClass.code} · {selectedTimetableClass.startTime}–{selectedTimetableClass.endTime} · {selectedTimetableClass.room}
|
||||
</AppText>
|
||||
</Card>
|
||||
<View style={styles.sheetButtons}>
|
||||
<Button
|
||||
label="View subject"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
const id = selectedTimetableClass.subjectId;
|
||||
setSelectedTimetableClass(null);
|
||||
router.push(`/subjects/${id}` as never);
|
||||
}}
|
||||
leading={<Ionicons name="book-outline" size={18} color={colors.brand.cobalt} />}
|
||||
/>
|
||||
<Button
|
||||
label="Delete recurring class"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Delete recurring class?',
|
||||
'This will stop this class from repeating in future weeks. Past class records will be preserved.',
|
||||
[
|
||||
{ text: 'Keep', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await collegeApi.deleteTimetableClass(selectedTimetableClass.id);
|
||||
setSelectedTimetableClass(null);
|
||||
setRefresh((v) => v + 1);
|
||||
} catch (err) {
|
||||
Alert.alert('Couldn’t delete class', err instanceof Error ? err.message : 'Please try again.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
leading={<Ionicons name="trash-outline" size={18} color={colors.semantic.danger.text} />}
|
||||
/>
|
||||
</View>
|
||||
</> : null}
|
||||
</BottomSheet>
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
weekToolbar: { marginTop: spacing[6], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
|
||||
header: { paddingTop: spacing[2], minHeight: 0 },
|
||||
weekToolbar: { marginTop: spacing[3], marginBottom: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[2] },
|
||||
weekCopy: { flex: 1, alignItems: 'center' },
|
||||
toolbarActions: { flexDirection: 'row', alignItems: 'center' },
|
||||
weekStrip: { marginTop: spacing[1] },
|
||||
sectionHeader: { marginTop: spacing[8], marginBottom: spacing[4], flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing[4] },
|
||||
sectionCopy: { flex: 1 },
|
||||
sectionSub: { marginTop: spacing[1] },
|
||||
@@ -158,4 +224,6 @@ const styles = StyleSheet.create({
|
||||
railLine: { position: 'absolute', top: 58, bottom: -10, left: 4, width: 1, backgroundColor: colors.neutral.divider },
|
||||
event: { flex: 1, marginBottom: spacing[2] },
|
||||
note: { marginTop: spacing[6] },
|
||||
selectedClassSummary: { marginTop: spacing[2] },
|
||||
sheetButtons: { gap: spacing[2], marginTop: spacing[5] },
|
||||
});
|
||||
|
||||
@@ -121,11 +121,11 @@ function Info({ icon, label, value }: { icon: keyof typeof Ionicons.glyphMap; la
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingBottom: spacing[9] },
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
loading: { minHeight: 220, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
error: { marginTop: spacing[6] },
|
||||
profileHero: { marginTop: spacing[5], alignItems: 'center', borderRadius: radius.feature, backgroundColor: colors.brand.sky, padding: spacing[7] },
|
||||
avatar: { width: 82, height: 82, borderRadius: 27, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
|
||||
error: { marginTop: spacing[4] },
|
||||
profileHero: { marginTop: spacing[3], alignItems: 'center', borderRadius: radius.feature, borderCurve: 'continuous', backgroundColor: colors.brand.sky, padding: spacing[7] },
|
||||
avatar: { width: 82, height: 82, borderRadius: 27, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
|
||||
avatarDot: { position: 'absolute', right: 2, top: 2, width: 14, height: 14, borderRadius: 7, backgroundColor: colors.brand.coral, borderWidth: 3, borderColor: colors.neutral.surface },
|
||||
name: { marginTop: spacing[4], textAlign: 'center' },
|
||||
college: { marginTop: spacing[1], textAlign: 'center' },
|
||||
@@ -135,7 +135,7 @@ const styles = StyleSheet.create({
|
||||
sectionTitle: { marginTop: spacing[8], marginBottom: spacing[3] },
|
||||
details: { overflow: 'hidden' },
|
||||
info: { minHeight: 78, flexDirection: 'row', alignItems: 'center', gap: spacing[3], paddingHorizontal: spacing[4], paddingVertical: spacing[3] },
|
||||
infoIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
|
||||
infoIcon: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
|
||||
infoCopy: { flex: 1 },
|
||||
infoValue: { marginTop: spacing[1] },
|
||||
divider: { height: 1, marginLeft: 68, marginRight: spacing[4], backgroundColor: colors.neutral.divider },
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function OnboardingScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingTop: spacing[5], paddingBottom: spacing[9] },
|
||||
brand: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
|
||||
logo: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobalt },
|
||||
logo: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobalt },
|
||||
progress: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[8] },
|
||||
progressSegment: { flex: 1, height: 5, borderRadius: radius.pill, backgroundColor: colors.neutral.divider },
|
||||
progressActive: { backgroundColor: colors.brand.cobalt },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Switch, View } from 'react-native';
|
||||
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import { collegeApi } from '@/lib/api';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [college, setCollege] = useState('');
|
||||
const [programme, setProgramme] = useState('');
|
||||
@@ -76,7 +78,7 @@ export default function SettingsScreen() {
|
||||
};
|
||||
|
||||
return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader title="Settings" />
|
||||
<AppHeader title="Settings" style={styles.header} />
|
||||
|
||||
{loading ? <View style={styles.loading}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary}>Loading settings…</AppText></View> : null}
|
||||
{loadError ? <InlineBanner title="Couldn’t load settings" message={loadError} tone="danger" action={<Button label="Retry" variant="ghost" size="compact" fullWidth={false} onPress={load} />} style={styles.firstBlock} /> : null}
|
||||
@@ -155,16 +157,27 @@ export default function SettingsScreen() {
|
||||
</Card>
|
||||
|
||||
<Button label="Save settings" loading={saving} haptic="success" onPress={save} style={styles.save} />
|
||||
|
||||
<AppText variant="heading3" style={styles.sectionTitle}>Account</AppText>
|
||||
<Card style={styles.card} padding={0}>
|
||||
<Button
|
||||
label="View full account profile"
|
||||
variant="secondary"
|
||||
onPress={() => router.push('/account' as never)}
|
||||
leading={<Ionicons name="person-outline" size={18} color={colors.brand.cobalt} />}
|
||||
/>
|
||||
</Card>
|
||||
</> : null}
|
||||
</Screen>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingBottom: spacing[9] },
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
header: { paddingTop: spacing[2], minHeight: 0 },
|
||||
loading: { minHeight: 240, alignItems: 'center', justifyContent: 'center', gap: spacing[3] },
|
||||
firstBlock: { marginTop: spacing[6] },
|
||||
intro: { marginTop: spacing[5], flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
|
||||
introIcon: { width: 48, height: 48, borderRadius: radius.card, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
|
||||
firstBlock: { marginTop: spacing[4] },
|
||||
intro: { marginTop: spacing[3], flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
|
||||
introIcon: { width: 48, height: 48, borderRadius: radius.card, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.neutral.surface },
|
||||
introCopy: { flex: 1 },
|
||||
introText: { marginTop: spacing[1] },
|
||||
feedback: { marginTop: spacing[4] },
|
||||
@@ -172,7 +185,7 @@ const styles = StyleSheet.create({
|
||||
card: { padding: spacing[5] },
|
||||
fields: { gap: spacing[4] },
|
||||
settingHeading: { flexDirection: 'row', alignItems: 'center', gap: spacing[3] },
|
||||
settingIcon: { width: 40, height: 40, borderRadius: radius.control, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
|
||||
settingIcon: { width: 40, height: 40, borderRadius: radius.control, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.brand.cobaltSoft },
|
||||
settingCopy: { flex: 1 },
|
||||
settingSub: { marginTop: spacing[1] },
|
||||
nestedField: { marginTop: spacing[5], marginLeft: 52 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { ActivityIndicator, Alert, StyleSheet, View } from 'react-native';
|
||||
|
||||
import {
|
||||
AppHeader,
|
||||
@@ -94,6 +94,28 @@ export default function SubjectDetailsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSubject = () => {
|
||||
Alert.alert(
|
||||
'Delete subject?',
|
||||
`Are you sure you want to delete ${data?.name}? This will remove its timetable classes and attendance records.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await collegeApi.deleteSubject(subjectId);
|
||||
router.back();
|
||||
} catch (err) {
|
||||
Alert.alert('Couldn’t delete subject', err instanceof Error ? err.message : 'Please try again.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && !data) return <Screen scroll={false} contentContainerStyle={styles.center}><ActivityIndicator color={colors.brand.cobalt} /><AppText variant="bodySmall" color={colors.neutral.textSecondary} style={styles.loadingText}>Loading subject…</AppText></Screen>;
|
||||
if (!data) return <Screen contentContainerStyle={styles.content}>
|
||||
<AppHeader compact title="Subject details" leading={<IconButton icon="chevron-back" label="Go back" onPress={() => router.back()} />} />
|
||||
@@ -141,6 +163,7 @@ export default function SubjectDetailsScreen() {
|
||||
<FormField label="Default room or location" value={defaultRoom} onChangeText={setDefaultRoom} placeholder="e.g. B-204 or Lab 3" hint="Used automatically when you add a class." autoCapitalize="words" />
|
||||
</View>
|
||||
<Button label="Save changes" loading={saving} haptic="success" onPress={save} />
|
||||
<Button label="Delete subject" variant="ghost" onPress={deleteSubject} leading={<Ionicons name="trash-outline" size={18} color={colors.semantic.danger.text} />} style={styles.deleteButton} />
|
||||
</Card> : <>
|
||||
<InlineBanner title={summaryCopy.title} message={summaryCopy.message} tone={summaryTone} style={styles.insight} />
|
||||
<Card padding={0} style={styles.stats}>
|
||||
@@ -182,13 +205,13 @@ function Stat({ value, label }: { value: number; label: string }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { paddingBottom: spacing[9] },
|
||||
content: { paddingTop: spacing[1], paddingBottom: spacing[9] },
|
||||
center: { alignItems: 'center', justifyContent: 'center' },
|
||||
loadingText: { marginTop: spacing[3] },
|
||||
error: { marginTop: spacing[6] },
|
||||
hero: { marginTop: spacing[5], borderRadius: radius.feature, padding: spacing[6] },
|
||||
error: { marginTop: spacing[4] },
|
||||
hero: { marginTop: spacing[3], borderRadius: radius.feature, borderCurve: 'continuous', padding: spacing[6] },
|
||||
heroIdentity: { flexDirection: 'row', alignItems: 'center', gap: spacing[4] },
|
||||
badge: { width: 52, height: 52, borderRadius: 17 },
|
||||
badge: { width: 52, height: 52, borderRadius: 17, borderCurve: 'continuous' },
|
||||
heroCopy: { flex: 1 },
|
||||
heroMeta: { marginTop: spacing[1] },
|
||||
heroProgress: { marginTop: spacing[6], flexDirection: 'row', alignItems: 'flex-end', gap: spacing[5] },
|
||||
@@ -197,6 +220,7 @@ const styles = StyleSheet.create({
|
||||
editCard: { marginTop: spacing[4] },
|
||||
formError: { marginTop: spacing[4] },
|
||||
fields: { gap: spacing[4], marginVertical: spacing[5] },
|
||||
deleteButton: { marginTop: spacing[3] },
|
||||
insight: { marginTop: spacing[4] },
|
||||
stats: { minHeight: 92, marginTop: spacing[3], flexDirection: 'row', alignItems: 'center' },
|
||||
stat: { flex: 1, alignItems: 'center', paddingVertical: spacing[5] },
|
||||
|
||||
Reference in New Issue
Block a user