From e596749ca0c7ff5ea99b85aa637a915ce11e1e1d Mon Sep 17 00:00:00 2001 From: Fernanda Toledo Date: Fri, 4 Sep 2026 17:17:51 -0300 Subject: [PATCH 1/3] refactor(router): move screen options from layouts into screen files --- src/app/(app)/_layout.tsx | 48 ++----------------------------------- src/app/(app)/index.tsx | 28 +++++++++++++++++++++- src/app/(app)/settings.tsx | 11 +++++++-- src/app/(app)/style.tsx | 9 +++++++ src/app/_layout.tsx | 23 ++++-------------- src/app/onboarding.tsx | 3 ++- src/app/sign-in.tsx | 2 ++ src/app/update-password.tsx | 3 ++- src/app/www.tsx | 17 ++++++------- 9 files changed, 64 insertions(+), 80 deletions(-) diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 66e3b8b7d..f16f4b856 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -1,13 +1,7 @@ -import { Link, Redirect, SplashScreen, Tabs } from 'expo-router'; +import { Redirect, SplashScreen, Tabs } from 'expo-router'; import { useCallback, useEffect } from 'react'; import { useAuth } from '@/components/providers/auth'; -import { Pressable, Text } from '@/components/ui'; -import { - Feed as FeedIcon, - Settings as SettingsIcon, - Style as StyleIcon, -} from '@/components/ui/icons'; import { useIsFirstTime } from '@/lib'; export default function TabLayout() { @@ -29,43 +23,5 @@ export default function TabLayout() { if (!isAuthenticated && ready) { return ; } - return ( - - , - headerRight: () => , - tabBarButtonTestID: 'feed-tab', - }} - /> - , - tabBarButtonTestID: 'style-tab', - }} - /> - , - tabBarButtonTestID: 'settings-tab', - }} - /> - - ); -} - -function CreateNewPostLink() { - return ( - - - Create - - - ); + return ; } diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index b5ca90b4c..917e3f3ac 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,9 +1,35 @@ -import { FocusAwareStatusBar, View } from '@/components/ui'; +import { Link, Tabs } from 'expo-router'; + +import { + FocusAwareStatusBar, + Pressable, + Text, + View, +} from '@/components/ui'; +import { Feed as FeedIcon } from '@/components/ui/icons'; export default function Feed() { return ( + , + headerRight: () => , + tabBarButtonTestID: 'feed-tab', + }} + /> ); } + +function CreateNewPostLink() { + return ( + + + Create + + + ); +} diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 2ad5ed049..a43183d47 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { Link } from 'expo-router'; +import { Link, Tabs } from 'expo-router'; import { useColorScheme } from 'nativewind'; import * as React from 'react'; import { showMessage } from 'react-native-flash-message'; @@ -18,7 +18,7 @@ import { Text, View, } from '@/components/ui'; -import { Website } from '@/components/ui/icons'; +import { Settings as SettingsIcon, Website } from '@/components/ui/icons'; import { translate } from '@/lib'; import { Env } from '@/lib/env'; @@ -44,6 +44,13 @@ export default function Settings() { return ( <> + , + tabBarButtonTestID: 'settings-tab', + }} + /> diff --git a/src/app/(app)/style.tsx b/src/app/(app)/style.tsx index 2731161e6..23bab5ead 100644 --- a/src/app/(app)/style.tsx +++ b/src/app/(app)/style.tsx @@ -1,3 +1,4 @@ +import { Tabs } from 'expo-router'; import * as React from 'react'; import { Buttons } from '@/components/buttons'; @@ -5,10 +6,18 @@ import { Colors } from '@/components/colors'; import { Inputs } from '@/components/inputs'; import { Typography } from '@/components/typography'; import { FocusAwareStatusBar, SafeAreaView, ScrollView } from '@/components/ui'; +import { Style as StyleIcon } from '@/components/ui/icons'; export default function Style() { return ( <> + , + tabBarButtonTestID: 'style-tab', + }} + /> diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index a105f26b8..5cf425777 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -4,7 +4,6 @@ import { ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as React from 'react'; -import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; import FlashMessage from 'react-native-flash-message'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -37,38 +36,24 @@ SplashScreen.setOptions({ function GuardedStack() { const { isAuthenticated } = useAuth(); - const { t } = useTranslation(); const [isFirstTime] = useIsFirstTime(); return ( - + - - + + - + - - ); } diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 308b7088d..db22264e0 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -1,4 +1,4 @@ -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { Cover } from '@/components/cover'; import { @@ -17,6 +17,7 @@ export default function Onboarding() { return ( + diff --git a/src/app/sign-in.tsx b/src/app/sign-in.tsx index a12412c8f..69f50a712 100644 --- a/src/app/sign-in.tsx +++ b/src/app/sign-in.tsx @@ -1,4 +1,5 @@ import type { LoginFormProps } from '@/components/login-form'; +import { Stack } from 'expo-router'; import * as React from 'react'; import { showMessage } from 'react-native-flash-message'; @@ -16,6 +17,7 @@ export default function Login() { }; return ( <> + diff --git a/src/app/update-password.tsx b/src/app/update-password.tsx index 923f0740b..b8f9121af 100644 --- a/src/app/update-password.tsx +++ b/src/app/update-password.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { useNavigation } from 'expo-router'; +import { Stack, useNavigation } from 'expo-router'; import * as React from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -64,6 +64,7 @@ export default function UpdatePassword() { return ( <> + diff --git a/src/app/www.tsx b/src/app/www.tsx index 6c24b3268..b871c70a2 100644 --- a/src/app/www.tsx +++ b/src/app/www.tsx @@ -1,6 +1,5 @@ -import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import * as React from 'react'; -import { useEffect } from 'react'; import { View } from 'react-native'; import { WebView } from 'react-native-webview'; @@ -10,19 +9,16 @@ import { translate } from '@/lib'; export default function WWW() { const router = useRouter(); const { url, title } = useLocalSearchParams(); - const navigation = useNavigation(); - useEffect(() => { - if (title !== undefined) { - navigation.setOptions({ - title, - }); - } - }, [navigation, title]); + const screenOptions = { + presentation: 'modal', + title: typeof title === 'string' ? title : '', + } as const; if (url === undefined || typeof url !== 'string') { return ( + {translate('www.invalidUrl')} @@ -32,6 +28,7 @@ export default function WWW() { return ( + Date: Thu, 15 Oct 2026 13:30:37 -0300 Subject: [PATCH 2/3] fix(router): address review feedback on screen options refactor Translate tab titles and the Create link, keep the (app) header hidden, and render a single Stack.Screen in www. --- src/app/(app)/_layout.tsx | 9 +++++++-- src/app/(app)/index.tsx | 9 +++++++-- src/app/(app)/settings.tsx | 2 +- src/app/(app)/style.tsx | 5 ++++- src/app/www.tsx | 37 +++++++++++++++++++------------------ src/translations/en.json | 5 +++++ 6 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index f16f4b856..1c5e6a3d2 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -1,4 +1,4 @@ -import { Redirect, SplashScreen, Tabs } from 'expo-router'; +import { Redirect, SplashScreen, Stack, Tabs } from 'expo-router'; import { useCallback, useEffect } from 'react'; import { useAuth } from '@/components/providers/auth'; @@ -23,5 +23,10 @@ export default function TabLayout() { if (!isAuthenticated && ready) { return ; } - return ; + return ( + <> + + + + ); } diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 917e3f3ac..9550ca1b3 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,4 +1,5 @@ import { Link, Tabs } from 'expo-router'; +import { useTranslation } from 'react-i18next'; import { FocusAwareStatusBar, @@ -9,11 +10,13 @@ import { import { Feed as FeedIcon } from '@/components/ui/icons'; export default function Feed() { + const { t } = useTranslation(); + return ( , headerRight: () => , tabBarButtonTestID: 'feed-tab', @@ -25,10 +28,12 @@ export default function Feed() { } function CreateNewPostLink() { + const { t } = useTranslation(); + return ( - Create + {t('tabs.create')} ); diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index a43183d47..3207fe1fb 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -46,7 +46,7 @@ export default function Settings() { <> , tabBarButtonTestID: 'settings-tab', }} diff --git a/src/app/(app)/style.tsx b/src/app/(app)/style.tsx index 23bab5ead..128d1a3bb 100644 --- a/src/app/(app)/style.tsx +++ b/src/app/(app)/style.tsx @@ -1,5 +1,6 @@ import { Tabs } from 'expo-router'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { Buttons } from '@/components/buttons'; import { Colors } from '@/components/colors'; @@ -9,11 +10,13 @@ import { FocusAwareStatusBar, SafeAreaView, ScrollView } from '@/components/ui'; import { Style as StyleIcon } from '@/components/ui/icons'; export default function Style() { + const { t } = useTranslation(); + return ( <> , tabBarButtonTestID: 'style-tab', }} diff --git a/src/app/www.tsx b/src/app/www.tsx index b871c70a2..8e38d49e9 100644 --- a/src/app/www.tsx +++ b/src/app/www.tsx @@ -15,25 +15,26 @@ export default function WWW() { title: typeof title === 'string' ? title : '', } as const; - if (url === undefined || typeof url !== 'string') { - return ( - - - - {translate('www.invalidUrl')} - - - ); - } - return ( - + <> - router.back()} - /> - + {typeof url === 'string' + ? ( + + router.back()} + /> + + ) + : ( + + + {translate('www.invalidUrl')} + + + )} + ); } diff --git a/src/translations/en.json b/src/translations/en.json index 58d57b6eb..d4df1f96f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -103,6 +103,11 @@ "version": "Version", "website": "Website" }, + "tabs": { + "create": "Create", + "feed": "Feed", + "style": "Style" + }, "updatePassword": { "buttonLabel": "Update Password", "confirmPasswordLabel": "Confirm Password", From 46fff28b18802a8931216ae0a5bc48fcbefd40a1 Mon Sep 17 00:00:00 2001 From: Fernanda Toledo Date: Wed, 21 Oct 2026 12:35:37 -0300 Subject: [PATCH 3/3] test(router): cover the screen options moved into screen files Sonar's new-code coverage gate failed because none of the screens touched by the refactor had tests. Cover the www modal (valid url, missing or repeated url, load error) and the header and tab options declared by feed, style, onboarding and sign in. --- __tests__/app/screen-options.test.tsx | 84 ++++++++++++++++++++++++++ __tests__/app/www.test.tsx | 85 +++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 __tests__/app/screen-options.test.tsx create mode 100644 __tests__/app/www.test.tsx diff --git a/__tests__/app/screen-options.test.tsx b/__tests__/app/screen-options.test.tsx new file mode 100644 index 000000000..67e325205 --- /dev/null +++ b/__tests__/app/screen-options.test.tsx @@ -0,0 +1,84 @@ +import { Stack, Tabs } from 'expo-router'; + +import Feed from '@/app/(app)/index'; +import Style from '@/app/(app)/style'; +import Onboarding from '@/app/onboarding'; +import Login from '@/app/sign-in'; +import { render, screen } from '@/lib/test-utils'; + +jest.mock('expo-router', () => ({ + Stack: { Screen: jest.fn(() => null) }, + Tabs: { Screen: jest.fn(() => null) }, + Link: ({ children }: { children: React.ReactNode }) => children, + useRouter: jest.fn(() => ({ replace: jest.fn() })), +})); + +jest.mock('@/components/ui', () => ({ + ...jest.requireActual('@/components/ui'), + FocusAwareStatusBar: () => null, +})); + +jest.mock('@/components/ui/icons', () => ({ + Feed: () => null, + Style: () => null, +})); + +jest.mock('@/components/buttons', () => ({ Buttons: () => null })); +jest.mock('@/components/colors', () => ({ Colors: () => null })); +jest.mock('@/components/inputs', () => ({ Inputs: () => null })); +jest.mock('@/components/typography', () => ({ Typography: () => null })); +jest.mock('@/components/cover', () => ({ Cover: () => null })); +jest.mock('@/components/login-form', () => ({ LoginForm: () => null })); + +jest.mock('@/lib/hooks', () => ({ + useIsFirstTime: jest.fn(() => [true, jest.fn()]), +})); + +jest.mock('@/api/auth/use-login', () => ({ + useLogin: jest.fn(() => ({ mutate: jest.fn(), isPending: false })), +})); + +const mockStackScreen = Stack.Screen as unknown as jest.Mock; +const mockTabsScreen = Tabs.Screen as unknown as jest.Mock; + +const optionsOf = (mock: jest.Mock) => mock.mock.calls[0][0].options; + +describe('screen options declared by each screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('titles the feed tab and adds a create link to its header', () => { + render(); + + const options = optionsOf(mockTabsScreen); + expect(options).toMatchObject({ + title: 'Feed', + tabBarButtonTestID: 'feed-tab', + }); + + render(options.headerRight()); + expect(screen.getByText('Create')).toBeOnTheScreen(); + }); + + it('titles the style tab', () => { + render(