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 @@ - - - - - information-circle - - - - - - - - - - - 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 };