diff --git a/App.tsx b/App.tsx index 9d3526f5..11b87196 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import analytics from '@react-native-firebase/analytics'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { View, useColorScheme } from 'react-native'; +import { View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { MenuProvider } from 'react-native-popup-menu'; import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'; @@ -21,6 +21,7 @@ import { GameSheetContextProvider } from './src/components/Sheets/GameSheetConte import { PointValuesSheetContextProvider } from './src/components/Sheets/PointValuesSheetContext'; import { SplashOverlay } from './src/components/SplashOverlay'; import { Navigation } from './src/Navigation'; +import { useTheme } from './src/theme'; // Keep the native splash screen up until our animated SplashOverlay mounts and // calls hideAsync(). Without this, Expo auto-hides the native splash on first JS @@ -31,32 +32,50 @@ if (process.env.EXPO_PUBLIC_FIREBASE_ANALYTICS == 'false') { analytics().setAnalyticsCollectionEnabled(false); } -export default function App() { - const colorScheme = useColorScheme(); - const bgColor = colorScheme === 'dark' ? '#000000' : '#F2F2F7'; +const AppContent = () => { + const theme = useTheme(); + const [appReady, setAppReady] = useState(false); const [splashDone, setSplashDone] = useState(false); + const handleBeforeLift = useCallback(() => setAppReady(true), []); + const handleSplashDone = useCallback(() => setSplashDone(true), []); return ( - + - - - - - - + + + + + - - - - - + + + + - {!splashDone && setSplashDone(true)} />} + {!splashDone && ( + + )} ); }; + +export default function App() { + return ( + + + + ); +}; diff --git a/src/components/SplashOverlay.test.tsx b/src/components/SplashOverlay.test.tsx new file mode 100644 index 00000000..06a1bdff --- /dev/null +++ b/src/components/SplashOverlay.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { act, render } from '@testing-library/react-native'; +import * as SplashScreen from 'expo-splash-screen'; +import { StyleSheet } from 'react-native'; +import { withDelay, withTiming } from 'react-native-reanimated'; + +import { SplashOverlay } from './SplashOverlay'; + +jest.mock('expo-image', () => ({ + Image: () => null, +})); + +jest.mock('expo-splash-screen', () => ({ + hideAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock('react-native-reanimated', () => { + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: { View }, + Easing: { + cubic: 'cubic', + in: jest.fn(value => value), + out: jest.fn(value => value), + quad: 'quad', + }, + runOnJS: jest.fn(callback => callback), + useAnimatedStyle: jest.fn(callback => callback()), + useSharedValue: jest.fn(value => ({ value })), + withDelay: jest.fn((_delay, animation) => animation), + withSequence: jest.fn((...animations) => animations.at(-1)), + withTiming: jest.fn((value, _config, callback) => { + callback?.(true); + return value; + }), + }; +}); + +describe('SplashOverlay', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps the overlay visible until persisted state is ready', () => { + const onDone = jest.fn(); + const view = render( + + ); + + expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); + expect(withDelay).not.toHaveBeenCalled(); + expect(withTiming).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + + act(() => { + view.rerender( + + ); + }); + + expect(withDelay).toHaveBeenCalled(); + expect(withTiming).toHaveBeenCalled(); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('uses the persisted app theme color', () => { + const { getByTestId } = render( + + ); + + expect(StyleSheet.flatten(getByTestId('splash-overlay').props.style)).toMatchObject({ + backgroundColor: '#123456', + }); + }); +}); diff --git a/src/components/SplashOverlay.tsx b/src/components/SplashOverlay.tsx index 6f546db2..0c4cf1bc 100644 --- a/src/components/SplashOverlay.tsx +++ b/src/components/SplashOverlay.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react'; import { Image } from 'expo-image'; import * as SplashScreen from 'expo-splash-screen'; -import { StyleSheet, useColorScheme } from 'react-native'; +import { StyleSheet } from 'react-native'; import Animated, { Easing, runOnJS, @@ -16,13 +16,12 @@ import Animated, { import icon from '../../assets/icon.png'; interface Props { + backgroundColor: string; onDone: () => void; + ready: boolean; } -export const SplashOverlay: React.FC = ({ onDone }) => { - const colorScheme = useColorScheme(); - const bgColor = colorScheme === 'dark' ? '#000000' : '#F2F2F7'; - +export const SplashOverlay: React.FC = ({ backgroundColor, onDone, ready }) => { const overlayOpacity = useSharedValue(1); const iconOpacity = useSharedValue(0); const iconScale = useSharedValue(0.92); @@ -30,7 +29,11 @@ export const SplashOverlay: React.FC = ({ onDone }) => { const runDone = useCallback(() => onDone(), [onDone]); useEffect(() => { - SplashScreen.hideAsync(); + void SplashScreen.hideAsync().catch(() => undefined); + }, []); + + useEffect(() => { + if (!ready) return; iconOpacity.value = withTiming(1, { duration: 350 }); @@ -44,7 +47,7 @@ export const SplashOverlay: React.FC = ({ onDone }) => { withTiming(1, { duration: 450, easing: Easing.out(Easing.cubic) }), withDelay(250, withTiming(1.04, { duration: 350, easing: Easing.in(Easing.quad) })) ); - }, []); + }, [ready]); const overlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value, @@ -57,8 +60,9 @@ export const SplashOverlay: React.FC = ({ onDone }) => { return (