diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 87ed7ec..d7e36b0 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,18 +1,23 @@ import { Tabs } from 'expo-router'; +import { GlobalAddClassProvider } from '@/components/global-add-class'; import { BottomTabBar } from '@/components/ui/bottom-tab-bar'; export default function TabLayout() { - return } - screenOptions={{ - headerShown: false, - // The visible dock is independently absolute; this removes React Navigation's reserved tab-bar scene area. - tabBarStyle: { position: 'absolute', height: 0, backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0 }, - }}> - - - - - ; + return ( + + } + screenOptions={{ + headerShown: false, + // The visible dock is independently absolute; this removes React Navigation's reserved tab-bar scene area. + tabBarStyle: { position: 'absolute', height: 0, backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0 }, + }}> + + + + + + + ); } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 7b990dc..5796e64 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { AddClassModal } from '@/components/add-class-modal'; +import { useGlobalAddClass } from '@/components/global-add-class'; import { AppHeader, AppText, @@ -50,6 +51,7 @@ const daysInMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() export default function TodayScreen() { const router = useRouter(); + const { revision: globalAddRevision } = useGlobalAddClass(); const [activeDate, setActiveDate] = useState(() => new Date()); const [calendarMonth, setCalendarMonth] = useState(() => startOfMonth(new Date())); const [classes, setClasses] = useState([]); @@ -93,6 +95,10 @@ export default function TodayScreen() { useEffect(() => { loadSchedule(); }, [loadSchedule, refresh]); + useEffect(() => { + if (globalAddRevision > 0) setRefresh((current) => current + 1); + }, [globalAddRevision]); + useEffect(() => { collegeApi.attendanceSummary().then(({ subjects }) => { const total = subjects.reduce((value, subject) => value + subject.total, 0); diff --git a/components/global-add-class.tsx b/components/global-add-class.tsx new file mode 100644 index 0000000..84c442c --- /dev/null +++ b/components/global-add-class.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; + +import { AddClassModal } from '@/components/add-class-modal'; +import { dateKey } from '@/lib/date'; + +type GlobalAddClassValue = { + openAddClass: () => void; + revision: number; +}; + +const GlobalAddClassContext = createContext(null); + +export function GlobalAddClassProvider({ children }: { children: ReactNode }) { + const [visible, setVisible] = useState(false); + const [revision, setRevision] = useState(0); + const value = useMemo(() => ({ openAddClass: () => setVisible(true), revision }), [revision]); + + return ( + + {children} + setVisible(false)} + onAdded={() => setRevision((current) => current + 1)} + /> + + ); +} + +export function useGlobalAddClass() { + const value = useContext(GlobalAddClassContext); + if (!value) throw new Error('useGlobalAddClass must be used within GlobalAddClassProvider'); + return value; +} diff --git a/components/ui/bottom-tab-bar.tsx b/components/ui/bottom-tab-bar.tsx index 7673093..296f236 100644 --- a/components/ui/bottom-tab-bar.tsx +++ b/components/ui/bottom-tab-bar.tsx @@ -2,7 +2,8 @@ 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 { useGlobalAddClass } from '@/components/global-add-class'; import { colors, radius, shadow, size, spacing } from './tokens'; const tabConfig = { @@ -12,45 +13,123 @@ const tabConfig = { 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 - - {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 [styles.tab, focused && styles.tabActive, pressed && styles.pressed]}> - - {label} - ; - })} +/** Compact floating navigation group paired with a persistent global add action. */ +export function BottomTabBar({ state, navigation, insets }: BottomTabBarProps) { + const { openAddClass } = useGlobalAddClass(); + const feedback = () => { + if (Platform.OS !== 'web') void Haptics.selectionAsync(); + }; + + const addClass = () => { + feedback(); + navigation.navigate('index'); + // Defer until the Today scene is active so the modal is not dismissed by the tab transition. + setTimeout(openAddClass, 0); + }; + + return ( + + + {state.routes.map((route, index) => { + const focused = state.index === index; + const config = tabConfig[route.name as keyof typeof tabConfig]; + if (!config) return null; + + const onPress = () => { + const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true }); + if (!focused && !event.defaultPrevented) { + feedback(); + navigation.navigate(route.name); + } + }; + + const onLongPress = () => navigation.emit({ type: 'tabLongPress', target: route.key }); + + return ( + [styles.tab, focused && styles.tabActive, pressed && styles.pressed]}> + + + ); + })} + + + [styles.addButton, pressed && styles.addPressed]}> + + - ; + ); } const styles = StyleSheet.create({ - floatingArea: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: spacing[4], paddingTop: spacing[3], backgroundColor: 'transparent' }, - bar: { minHeight: size.touchTargetMin + spacing[3], flexDirection: 'row', alignItems: 'center', gap: 2, padding: spacing[2], borderRadius: radius.sheet, borderCurve: 'continuous', 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: 2, borderRadius: radius.pill, borderCurve: 'continuous', paddingHorizontal: spacing[1] }, - tabActive: { flexDirection: 'row', flexGrow: 1.15, gap: spacing[1] + 2, backgroundColor: colors.brand.cobalt, paddingHorizontal: spacing[3] }, - label: { textAlign: 'center', flexShrink: 0 }, - pressed: { opacity: 0.76 }, + floatingArea: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing[3], + paddingHorizontal: spacing[5], + paddingTop: spacing[3], + backgroundColor: 'transparent', + }, + navigationGroup: { + flex: 1, + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing[1], + padding: spacing[2], + borderRadius: radius.feature, + borderCurve: 'continuous', + borderWidth: 1, + borderColor: '#DCEEF8', + backgroundColor: colors.brand.skySoft, + ...shadow.floating, + }, + tab: { + minWidth: size.touchTargetMin, + minHeight: size.touchTargetMin, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.control, + borderCurve: 'continuous', + }, + tabActive: { + backgroundColor: colors.brand.coral, + }, + addButton: { + width: 58, + height: 58, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.feature, + borderCurve: 'continuous', + backgroundColor: colors.brand.coral, + ...shadow.floating, + }, + pressed: { + opacity: 0.76, + transform: [{ scale: 0.97 }], + }, + addPressed: { + backgroundColor: colors.brand.coralSoft, + transform: [{ scale: 0.96 }], + }, }); diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index d05ee36..be891be 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -25,7 +25,7 @@ test.describe('College Kit E2E & Visual Verification', () => { await page.goto('/'); // Navigate to Timetable - await page.locator('text=Timetable').click(); + await page.getByRole('button', { name: 'Timetable' }).click(); await expect(page.locator('text=Week of')).toBeVisible(); // Navigate to Attendance @@ -43,6 +43,16 @@ test.describe('College Kit E2E & Visual Verification', () => { await expect(page.locator('text=classes').first()).toBeVisible(); }); + test('Global plus action opens Add Class from another tab', async ({ page }) => { + await page.goto('/attendance'); + await expect(page.locator('text=By subject')).toBeVisible(); + + await page.getByRole('button', { name: 'Add a class' }).click(); + + await expect(page.locator('text=Add a class').first()).toBeVisible(); + await expect(page.locator('text=One-off class for')).toBeVisible(); + }); + test('Account screen displays profile information correctly', async ({ page }) => { await page.goto('/account'); await expect(page.locator('text=Sam Taylor')).toBeVisible();