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();