diff --git a/app.json b/app.json
index 7573949..62de23f 100644
--- a/app.json
+++ b/app.json
@@ -55,6 +55,13 @@
{}
],
"expo-status-bar",
+ [
+ "expo-navigation-bar",
+ {
+ "enforceContrast": false,
+ "style": "dark"
+ }
+ ],
[
"expo-splash-screen",
{
diff --git a/assets/icons/calendar.svg b/assets/icons/calendar.svg
new file mode 100644
index 0000000..ff17e6e
--- /dev/null
+++ b/assets/icons/calendar.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/icons/event.svg b/assets/icons/event.svg
deleted file mode 100644
index 61804fe..0000000
--- a/assets/icons/event.svg
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/assets/icons/id-card.svg b/assets/icons/id-card.svg
new file mode 100644
index 0000000..1cfc862
--- /dev/null
+++ b/assets/icons/id-card.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/icons/info.svg b/assets/icons/info.svg
new file mode 100644
index 0000000..990dc7f
--- /dev/null
+++ b/assets/icons/info.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/assets/icons/information-circle.svg b/assets/icons/information-circle.svg
deleted file mode 100644
index d8cc40f..0000000
--- a/assets/icons/information-circle.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
diff --git a/assets/icons/person.svg b/assets/icons/person.svg
deleted file mode 100644
index 554be01..0000000
--- a/assets/icons/person.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
diff --git a/jest.setup.js b/jest.setup.js
index f0120d5..eb42b83 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -29,6 +29,9 @@ jest.mock('@react-native-async-storage/async-storage', () =>
jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js'));
+// Navigation sizes the tab bar from the bottom inset; the mock answers with zeros without a provider
+jest.mock('react-native-safe-area-context', () => require('react-native-safe-area-context/jest/mock').default);
+
// The preset's RefreshControl mock drops its props, so onRefresh and testID never reach the host tree
jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => {
const React = require('react');
diff --git a/package-lock.json b/package-lock.json
index b2d1c82..0484cb5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"expo-constants": "~57.0.17",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
+ "expo-navigation-bar": "~57.0.2",
"expo-notifications": "~57.0.17",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
@@ -54,7 +55,7 @@
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
"prettier": "^3.9.6",
- "test-renderer": "1.2",
+ "test-renderer": "~1.2.0",
"typescript": "~6.0.3"
}
},
@@ -8488,6 +8489,20 @@
"react-native": "*"
}
},
+ "node_modules/expo-navigation-bar": {
+ "version": "57.0.2",
+ "resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-57.0.2.tgz",
+ "integrity": "sha512-Ufe49dsTZjarA7gMMwwwnNJWpXo+oTyl5Ud0g3lsGVrj2HXjyw9cb9098N8+CoWpjfIa5PghuRYYXrcCrGQIHg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.2"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-notifications": {
"version": "57.0.17",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.17.tgz",
diff --git a/package.json b/package.json
index 8d52506..b99eb81 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"expo-constants": "~57.0.17",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
+ "expo-navigation-bar": "~57.0.2",
"expo-notifications": "~57.0.17",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
diff --git a/src/App.tsx b/src/App.tsx
index 7ce7b45..7299109 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import * as Notifications from 'expo-notifications';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-context';
import 'expo-dev-client';
import { AuthProvider } from '@/auth/AuthContext';
@@ -27,19 +28,21 @@ export default function App() {
useDeepLinking();
return (
-
-
-
- {/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
-
-
-
-
-
+
+
+
+
+ {/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
+
+
+
+
+
+
);
}
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f9601be..f9faf1c 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -2,8 +2,11 @@ import { BottomTabNavigationOptions, createBottomTabNavigator } from '@react-nav
import { DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import React from 'react';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAuth } from '@/auth/AuthContext';
+import TabButton from '@/components/TabButton';
+import TabIcon, { PILL_SIZE } from '@/components/TabIcon';
import About from '@/modules/about/About';
import EventList from '@/modules/events/EventList';
import Login from '@/modules/login/Login';
@@ -14,9 +17,9 @@ import Settings from '@/modules/settings/Settings';
import SettingsHeaderButton from '@/modules/settings/SettingsHeaderButton';
import theme from '@/theme';
import type { EventStackParamList, HomeTabParamList, UserStackParamList } from '@/types/navigation';
-import IconEvent from '@assets/icons/event.svg';
-import IconInfo from '@assets/icons/information-circle.svg';
-import IconPerson from '@assets/icons/person.svg';
+import IconCalendar from '@assets/icons/calendar.svg';
+import IconIdCard from '@assets/icons/id-card.svg';
+import IconInfo from '@assets/icons/info.svg';
const defaultNavigationStyle = {
headerTintColor: theme.colors.white,
@@ -83,41 +86,61 @@ const UserStackScreen = () => {
type TabBarIconType = BottomTabNavigationOptions['tabBarIcon'];
const HomeTabs = createBottomTabNavigator();
-const EventIcon: TabBarIconType = ({ color, size }) => ;
-const UserIcon: TabBarIconType = ({ color, size }) => ;
-const AboutIcon: TabBarIconType = ({ color, size }) => ;
-const Navigation = () => (
-
-
-
-
-
-
-
-);
+const EventIcon: TabBarIconType = (props) => ;
+const UserIcon: TabBarIconType = (props) => ;
+const AboutIcon: TabBarIconType = (props) => ;
+
+const tabBarOptions: BottomTabNavigationOptions = {
+ tabBarActiveTintColor: theme.tabBar.activeTint,
+ tabBarInactiveTintColor: theme.tabBar.inactiveTint,
+ tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
+ tabBarIconStyle: PILL_SIZE,
+ tabBarButton: TabButton,
+};
+
+const Navigation = () => {
+ const insets = useSafeAreaInsets();
+ // Setting a height replaces the navigator's own inset handling, so the inset is added back here
+ const tabBarStyle = {
+ height: theme.tabBar.height + insets.bottom,
+ backgroundColor: theme.tabBar.background,
+ borderTopWidth: 0,
+ elevation: 0,
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
export default Navigation;
diff --git a/src/components/TabButton.tsx b/src/components/TabButton.tsx
new file mode 100644
index 0000000..72eab14
--- /dev/null
+++ b/src/components/TabButton.tsx
@@ -0,0 +1,15 @@
+import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
+import React from 'react';
+import { Pressable } from 'react-native';
+
+// The stock button's borderless Android ripple flashes a circle well past the tab; the pill is feedback enough
+const TabButton = ({
+ android_ripple,
+ pressColor,
+ pressOpacity,
+ hoverEffect,
+ ref,
+ ...props
+}: BottomTabBarButtonProps) => ;
+
+export default TabButton;
diff --git a/src/components/TabIcon.tsx b/src/components/TabIcon.tsx
new file mode 100644
index 0000000..e6bfa23
--- /dev/null
+++ b/src/components/TabIcon.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import theme from '@/theme';
+
+type SvgComponent = React.FunctionComponent>;
+
+export type TabIconProps = {
+ icon: SvgComponent;
+ focused: boolean;
+ color: string;
+ size: number;
+};
+
+export const PILL_SIZE = { width: 60, height: 32 };
+
+const TabIcon = ({ icon: Icon, focused, color, size }: TabIconProps) => (
+
+
+
+);
+
+const styles = StyleSheet.create({
+ pill: {
+ ...PILL_SIZE,
+ borderRadius: 999,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ pillActive: {
+ backgroundColor: theme.tabBar.pill.background,
+ },
+});
+
+export default TabIcon;
diff --git a/src/theme.ts b/src/theme.ts
index b0097c3..83024a9 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -38,6 +38,15 @@ const buttons = {
secondary: { background: colors.goldenBeige, text: colors.deepBrick },
};
+// Bottom tab bar: the screen's own surface with no edge, the active icon in a pill of the secondary button colours
+const tabBar = {
+ height: 56,
+ background: colors.betongGray,
+ activeTint: colors.chateauBlue,
+ inactiveTint: colors.chateauBlue05,
+ pill: buttons.secondary,
+};
+
// neuf.no's type scale at phone widths: body 1.5 line height, headings 600 at 1.2
const typography = {
h1: { fontSize: 32, fontWeight: '600', lineHeight: 38 },
@@ -49,4 +58,4 @@ const typography = {
suphead: { fontSize: 14, fontWeight: '700', lineHeight: 21, textTransform: 'uppercase', letterSpacing: 0.7 },
} as const;
-export default { card, colors, background, text, typography, buttons };
+export default { card, colors, background, text, typography, buttons, tabBar };